@inglorious/web 2.3.0 → 2.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.
package/README.md CHANGED
@@ -443,6 +443,36 @@ api.notify("navigate", "/users/456")
443
443
  api.notify("navigate", -1)
444
444
  ```
445
445
 
446
+ ### 4. Lazy Loading Routes
447
+
448
+ You can improve performance by lazy-loading routes. Instead of a string, provide a function that returns a dynamic import.
449
+
450
+ **Note:** The imported module must use a named export for the entity type (not `export default`), so the router can register it with a unique name in the store.
451
+
452
+ ```javascript
453
+ // store.js
454
+ const entities = {
455
+ router: {
456
+ type: "router",
457
+ routes: {
458
+ "/": "homePage",
459
+ // Lazy load: returns a Promise resolving to a module
460
+ "/admin": () => import("./pages/admin.js"),
461
+ },
462
+ },
463
+ }
464
+ ```
465
+
466
+ ```javascript
467
+ // pages/admin.js
468
+ import { html } from "@inglorious/web"
469
+
470
+ // Must be a named export matching the type name you want to use
471
+ export const adminPage = {
472
+ render: () => html`<h1>Admin Area</h1>`,
473
+ }
474
+ ```
475
+
446
476
  ---
447
477
 
448
478
  ## Table
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "2.3.0",
3
+ "version": "2.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": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -38,8 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "lit-html": "^3.3.1",
41
- "@inglorious/store": "7.1.3",
42
- "@inglorious/utils": "3.7.0"
41
+ "@inglorious/utils": "3.7.0",
42
+ "@inglorious/store": "7.1.4"
43
43
  },
44
44
  "devDependencies": {
45
45
  "prettier": "^3.6.2",
package/src/mount.js CHANGED
@@ -11,19 +11,20 @@ export function mount(store, renderFn, element) {
11
11
  const api = { ...store._api }
12
12
  api.render = createRender(api)
13
13
 
14
- let renderScheduled = false
14
+ // let renderScheduled = false
15
15
 
16
- const scheduleRender = () => {
17
- if (!renderScheduled) {
18
- renderScheduled = true
19
- requestAnimationFrame(() => {
20
- renderScheduled = false
21
- render(renderFn(api), element)
22
- })
23
- }
24
- }
16
+ // const scheduleRender = () => {
17
+ // if (!renderScheduled) {
18
+ // renderScheduled = true
19
+ // requestAnimationFrame(() => {
20
+ // renderScheduled = false
21
+ // })
22
+ // render(renderFn(api), element)
23
+ // }
24
+ // }
25
25
 
26
- const unsubscribe = store.subscribe(scheduleRender)
26
+ // const unsubscribe = store.subscribe(scheduleRender)
27
+ const unsubscribe = store.subscribe(() => render(renderFn(api), element))
27
28
  store.notify("init")
28
29
 
29
30
  return unsubscribe
package/src/router.js CHANGED
@@ -1,71 +1,58 @@
1
- const SKIP_FULL_MATCH_GROUP = 1 // .match() result at index 0 is the full string
2
- const REMOVE_COLON_PREFIX = 1
3
-
4
1
  /**
5
- * @typedef {Object} RouterEntity
6
- * @property {string} id - The unique identifier for the router entity.
7
- * @property {Object.<string, string>} routes - A map of URL patterns to entity types.
8
- * @property {string} [path] - The current URL path.
9
- * @property {string} [route] - The entity type for the current route.
10
- * @property {Object.<string, string>} [params] - The parameters extracted from the URL.
11
- * @property {Object.<string, string>} [query] - The query parameters from the URL.
12
- * @property {string} [hash] - The URL hash.
2
+ * @typedef {import("../types/router").RouterType} RouterType
3
+ * @typedef {import("../types/router").RouterEntity} RouterEntity
4
+ * @typedef {import("../types/router").Api} Api
13
5
  */
14
6
 
7
+ const SKIP_FULL_MATCH_GROUP = 1 // .match() result at index 0 is the full string
8
+ const REMOVE_COLON_PREFIX = 1
9
+
15
10
  /**
16
- * A client-side router type for an entity-based system. It handles URL changes,
17
- * link interception, and history management (pushState, replaceState, popstate).
18
- * @type {import('@inglorious/engine/types/type.js').Type}
11
+ * Client-side router for entity-based systems. Handles URL changes, link interception, and browser history management.
12
+ * @type {RouterType}
19
13
  */
20
14
  export const router = {
21
15
  /**
22
- * Initializes the router. It handles the initial route, sets up a `popstate`
23
- * listener for browser navigation, and intercepts clicks on local `<a>` links.
16
+ * Initializes the router entity.
17
+ * Sets up the initial route and event listeners for navigation.
24
18
  * @param {RouterEntity} entity - The router entity.
25
- * @param {*} payload - The message payload (unused).
26
- * @param {import('../types/router.js').RouterApi} api - The router API.
19
+ * @param {void} payload - Unused payload.
20
+ * @param {Api} api - The application API.
27
21
  */
28
22
  init(entity, payload, api) {
29
23
  // Handle initial route
30
- const initialPath = window.location.pathname
24
+ const { pathname, search } = window.location
25
+ const initialPath = pathname + search
31
26
  const route = findRoute(entity.routes, initialPath)
27
+ const entityId = entity.id
32
28
 
33
29
  if (route) {
34
- entity.path = route.path
35
- entity.route = route.entityType
36
- entity.params = route.params
37
-
38
- const query = Object.fromEntries(
39
- new URLSearchParams(window.location.search),
40
- )
41
- entity.query = query
42
- entity.hash = window.location.hash
30
+ api.notify(`#${entityId}:navigate`, {
31
+ to: initialPath,
32
+ params: route.params,
33
+ replace: true,
34
+ })
43
35
  }
44
36
 
45
- const id = entity.id
46
37
  // Listen for browser back/forward
47
38
  window.addEventListener("popstate", () => {
48
- const path = window.location.pathname
49
- const { routes } = api.getEntity(id)
39
+ const path = window.location.pathname + window.location.search
40
+ const { routes } = api.getEntity(entityId)
50
41
  const route = findRoute(routes, path)
51
42
 
52
43
  if (route) {
53
- api.notify("routeSync", {
54
- path: route.path,
44
+ api.notify(`#${entityId}:routeSync`, {
55
45
  entityType: route.entityType,
46
+ path,
56
47
  params: route.params,
57
- query: Object.fromEntries(
58
- new URLSearchParams(window.location.search),
59
- ),
60
- hash: window.location.hash,
61
48
  })
62
49
  }
63
50
  })
64
51
 
65
52
  // Intercept link clicks
66
- document.addEventListener("click", (e) => {
53
+ document.addEventListener("click", (event) => {
67
54
  // Find the closest <a> tag (handles clicks on children)
68
- const link = e.target.closest("a")
55
+ const link = event.target.closest("a")
69
56
 
70
57
  if (!link) return
71
58
 
@@ -84,7 +71,7 @@ export const router = {
84
71
  if (!["http:", "https:"].includes(link.protocol)) return
85
72
 
86
73
  // Prevent default and use router
87
- e.preventDefault()
74
+ event.preventDefault()
88
75
 
89
76
  const path = link.pathname + link.search + link.hash
90
77
  api.notify("navigate", path)
@@ -92,22 +79,16 @@ export const router = {
92
79
  },
93
80
 
94
81
  /**
95
- * Navigates to a new route, updating the browser's history and the router entity state.
82
+ * Handles navigation to a new route.
96
83
  * @param {RouterEntity} entity - The router entity.
97
- * @param {string|number|import('../types/router.js').NavigatePayload} payload - The navigation payload.
98
- * Can be a path string, a number for `history.go()`, or an object with navigation options.
99
- * @param {string|number} payload.to - The destination path or history offset.
100
- * @param {Object} [payload.params] - Route parameters to build the path from a pattern.
101
- * @param {boolean} [payload.replace] - If true, uses `history.replaceState` instead of `pushState`.
102
- * @param {Object} [payload.state] - Additional state to store in the browser's history.
103
- * @param {import('../types/router.js').RouterApi} api - The router API.
84
+ * @param {string|number|{to: string, params?: object, replace?: boolean, state?: object}} payload - The navigation target or options.
85
+ * @param {Api} api - The application API.
104
86
  */
105
- navigate(entity, payload, api) {
106
- if (["number", "string"].includes(typeof payload)) {
107
- payload = { to: payload }
108
- }
109
-
110
- const { to, params, replace, state = {} } = payload
87
+ async navigate(entity, payload, api) {
88
+ const options = ["number", "string"].includes(typeof payload)
89
+ ? { to: payload }
90
+ : payload
91
+ const { to, params, replace, state = {} } = options
111
92
 
112
93
  // Numeric navigation (back/forward)
113
94
  if (typeof to === "number") {
@@ -140,54 +121,98 @@ export const router = {
140
121
  return
141
122
  }
142
123
 
143
- // Parse query string
144
- const [pathname, search] = path.split("?")
145
- const query = search ? Object.fromEntries(new URLSearchParams(search)) : {}
146
-
147
- // Update entity with routing info
148
- entity.path = pathname
149
- entity.route = route.entityType
150
- entity.params = route.params
151
- entity.query = query
152
- entity.hash = window.location.hash
153
-
154
- // Prepare history state
155
- const historyState = {
156
- ...state,
157
- route: route.entityType,
158
- params: route.params,
159
- query,
160
- path: pathname,
124
+ // Asynchronous navigation
125
+ if (typeof route.entityType === "function") {
126
+ entity.loading = true
127
+ entity.error = null
128
+ const entityId = entity.id
129
+
130
+ try {
131
+ const module = await route.entityType()
132
+ api.notify(`#${entityId}:loadSuccess`, {
133
+ module,
134
+ route,
135
+ path,
136
+ replace,
137
+ state,
138
+ })
139
+ } catch (error) {
140
+ api.notify(`#${entityId}:loadError`, { error, path })
141
+ }
142
+
143
+ return
161
144
  }
162
145
 
163
- // Navigate
164
- const method = replace ? "replaceState" : "pushState"
165
- history[method](historyState, "", path)
146
+ // Synchronous navigation
147
+
148
+ doNavigate(
149
+ entity,
150
+ {
151
+ entityType: route.entityType,
152
+ path,
153
+ params: route.params,
154
+ replace,
155
+ state,
156
+ },
157
+ api,
158
+ )
159
+ },
160
+
161
+ /**
162
+ * Handles the successful loading of a lazy route module.
163
+ * @param {RouterEntity} entity - The router entity.
164
+ * @param {{module: object, route: object, path: string, replace: boolean, state: object}} payload - The success payload.
165
+ * @param {Api} api - The application API.
166
+ */
167
+ loadSuccess(entity, payload, api) {
168
+ const { module, route, path, replace, state } = payload
169
+
170
+ const [[typeName, type]] = Object.entries(module)
171
+
172
+ api.notify("morph", { name: typeName, type })
166
173
 
167
- api.notify("routeChange", historyState)
174
+ entity.routes[route.pattern] = typeName
175
+
176
+ // Complete the navigation
177
+ entity.loading = false
178
+
179
+ doNavigate(
180
+ entity,
181
+ { entityType: typeName, path, params: route.params, replace, state },
182
+ api,
183
+ )
184
+ },
185
+
186
+ /**
187
+ * Handles errors during lazy route loading.
188
+ * @param {RouterEntity} entity - The router entity.
189
+ * @param {{error: Error, path: string}} payload - The error payload.
190
+ */
191
+ loadError(entity, payload) {
192
+ const { error, path } = payload
193
+ console.error(`Failed to load route ${path}:`, error)
194
+ entity.path = path
195
+ entity.loading = false
196
+ entity.error = error
168
197
  },
169
198
 
170
199
  /**
171
- * Synchronizes the router entity's state with data from a routing event,
172
- * typically triggered by a `popstate` event (browser back/forward).
173
- * @param {RouterEntity} entity - The router entity to update.
174
- * @param {import('../types/router.js').RouteSyncPayload} payload - The new route state.
200
+ * Synchronizes the router state with the browser's history (e.g., on popstate).
201
+ * @param {RouterEntity} entity - The router entity.
202
+ * @param {{entityType: string, path: string, params: object}} payload - The sync payload.
175
203
  */
176
204
  routeSync(entity, payload) {
177
- entity.path = payload.path
178
- entity.route = payload.entityType
179
- entity.params = payload.params
180
- entity.query = payload.query
181
- entity.hash = payload.hash
205
+ updateRouter(entity, payload)
182
206
  },
183
207
  }
184
208
 
185
209
  /**
186
210
  * Builds a URL path by substituting parameters into a route pattern.
187
- * Example: `buildPath("/users/:userId", { userId: "123" })` returns `"/users/123"`.
188
211
  * @param {string} pattern - The route pattern (e.g., "/users/:userId").
189
- * @param {Object.<string, string>} [params={}] - The parameters to substitute.
212
+ * @param {Record<string, string>} [params={}] - The parameters to substitute.
190
213
  * @returns {string} The constructed path.
214
+ * @example
215
+ * buildPath("/users/:userId", { userId: "123" }) // returns "/users/123"
191
216
  */
192
217
  function buildPath(pattern, params = {}) {
193
218
  let path = pattern
@@ -200,9 +225,9 @@ function buildPath(pattern, params = {}) {
200
225
  /**
201
226
  * Finds a matching route configuration for a given URL path.
202
227
  * It supports parameterized routes and a fallback "*" route.
203
- * @param {Object.<string, string>} routes - The routes configuration map.
228
+ * @param {Record<string, string>} routes - The routes configuration map.
204
229
  * @param {string} path - The URL path to match.
205
- * @returns {{pattern: string, entityType: string, params: Object, path: string}|null}
230
+ * @returns {{pattern: string, entityType: string, params: Record<string, string>, path: string}|null}
206
231
  * The matched route object or null if no match is found.
207
232
  */
208
233
  function findRoute(routes, path) {
@@ -226,7 +251,7 @@ function findRoute(routes, path) {
226
251
  * Matches a URL path against a route pattern and extracts any parameters.
227
252
  * @param {string} pattern - The route pattern (e.g., "/users/:userId").
228
253
  * @param {string} path - The URL path to match (e.g., "/users/123").
229
- * @returns {Object.<string, string>|null} An object of extracted parameters,
254
+ * @returns {Record<string, string>|null} An object of extracted parameters,
230
255
  * or null if the path does not match the pattern.
231
256
  */
232
257
  function matchRoute(pattern, path) {
@@ -246,9 +271,10 @@ function matchRoute(pattern, path) {
246
271
 
247
272
  /**
248
273
  * Parses a route pattern and extracts the names of its parameters.
249
- * Example: `getParamNames("/users/:userId/posts/:postId")` returns `["userId", "postId"]`.
250
274
  * @param {string} pattern - The route pattern.
251
275
  * @returns {string[]} An array of parameter names.
276
+ * @example
277
+ * getParamNames("/users/:userId/posts/:postId") // returns ["userId", "postId"]
252
278
  */
253
279
  function getParamNames(pattern) {
254
280
  const matches = pattern.match(/:(\w+)/g)
@@ -266,3 +292,52 @@ function patternToRegex(pattern) {
266
292
  .replace(/:(\w+)/g, "([^\\/]+)")
267
293
  return new RegExp(`^${regexPattern}$`)
268
294
  }
295
+
296
+ /**
297
+ * Performs the actual navigation by updating entity state and browser history.
298
+ * @param {RouterEntity} entity - The router entity.
299
+ * @param {object} options - Navigation options.
300
+ * @param {string} options.entityType - The type of the entity to render.
301
+ * @param {string} options.path - The full path.
302
+ * @param {object} options.params - The route parameters.
303
+ * @param {boolean} [options.replace] - Whether to replace the current history entry.
304
+ * @param {object} [options.state] - Additional state to save in history.
305
+ * @param {Api} api - The application API.
306
+ */
307
+ function doNavigate(entity, { entityType, path, params, replace, state }, api) {
308
+ updateRouter(entity, { entityType, path, params })
309
+
310
+ // Prepare history state
311
+ const historyState = {
312
+ ...state,
313
+ route: entity.route,
314
+ params: entity.params,
315
+ query: entity.query,
316
+ path: entity.path,
317
+ }
318
+
319
+ // Navigate
320
+ const method = replace ? "replaceState" : "pushState"
321
+ history[method](historyState, "", path)
322
+
323
+ api.notify("routeChange", historyState)
324
+ }
325
+
326
+ /**
327
+ * Updates the router entity's internal state.
328
+ * @param {RouterEntity} entity - The router entity.
329
+ * @param {object} options - The update options.
330
+ * @param {string} options.entityType - The matched entity type.
331
+ * @param {string} options.path - The full path.
332
+ * @param {object} options.params - The extracted parameters.
333
+ */
334
+ function updateRouter(entity, { entityType, path, params }) {
335
+ const [pathname, search] = path.split("?")
336
+ const query = search ? Object.fromEntries(new URLSearchParams(search)) : {}
337
+
338
+ entity.path = pathname
339
+ entity.route = entityType
340
+ entity.params = params
341
+ entity.query = query
342
+ entity.hash = window.location.hash
343
+ }
@@ -54,11 +54,11 @@ describe("router", () => {
54
54
 
55
55
  router.init(entity, {}, api)
56
56
 
57
- expect(entity.path).toBe("/users/123")
58
- expect(entity.route).toBe("userPage")
59
- expect(entity.params).toEqual({ id: "123" })
60
- expect(entity.query).toEqual({ sort: "asc" })
61
- expect(entity.hash).toBe("#details")
57
+ expect(api.notify).toHaveBeenCalledWith("#router:navigate", {
58
+ to: "/users/123?sort=asc",
59
+ params: { id: "123" },
60
+ replace: true,
61
+ })
62
62
  })
63
63
 
64
64
  it("should set up a popstate listener", () => {
@@ -143,16 +143,60 @@ describe("router", () => {
143
143
  describe("routeSync()", () => {
144
144
  it("should update the entity state from a payload", () => {
145
145
  const payload = {
146
- path: "/new",
146
+ path: "/new?a=1",
147
147
  entityType: "newPage",
148
148
  params: {},
149
- query: { a: "1" },
150
- hash: "#section",
151
149
  }
150
+
151
+ vi.spyOn(window, "location", "get").mockReturnValue({ hash: "#section" })
152
+
152
153
  router.routeSync(entity, payload)
154
+
153
155
  expect(entity.path).toBe("/new")
154
156
  expect(entity.route).toBe("newPage")
155
157
  expect(entity.query).toEqual({ a: "1" })
158
+ expect(entity.hash).toBe("#section")
159
+ })
160
+ })
161
+
162
+ describe("loadSuccess()", () => {
163
+ it("should handle lazy loaded modules", () => {
164
+ const module = { myPage: { render: () => {} } }
165
+ const route = { pattern: "/lazy", params: {} }
166
+ const payload = {
167
+ module,
168
+ route,
169
+ path: "/lazy",
170
+ replace: false,
171
+ state: {},
172
+ }
173
+
174
+ router.loadSuccess(entity, payload, api)
175
+
176
+ expect(api.notify).toHaveBeenCalledWith("morph", {
177
+ name: "myPage",
178
+ type: module.myPage,
179
+ })
180
+ expect(entity.routes["/lazy"]).toBe("myPage")
181
+ expect(entity.loading).toBe(false)
182
+ expect(entity.route).toBe("myPage")
183
+ expect(history.pushState).toHaveBeenCalled()
184
+ })
185
+ })
186
+
187
+ describe("loadError()", () => {
188
+ it("should handle load errors", () => {
189
+ const error = new Error("Failed")
190
+ const payload = { error, path: "/lazy" }
191
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
192
+
193
+ router.loadError(entity, payload)
194
+
195
+ expect(entity.path).toBe("/lazy")
196
+ expect(entity.loading).toBe(false)
197
+ expect(entity.error).toBe(error)
198
+
199
+ consoleSpy.mockRestore()
156
200
  })
157
201
  })
158
202
  })
package/types/router.d.ts CHANGED
@@ -66,46 +66,65 @@ export interface RouteSyncPayload {
66
66
  }
67
67
 
68
68
  /**
69
- * The API object provided to the router for interacting with the host system.
69
+ * API from @inglorious/store
70
70
  */
71
- export interface RouterApi {
72
- /** Retrieves an entity by its ID. */
73
- getEntity(id: string | number): { routes: RoutesConfig }
74
- /** Dispatches an event to the system. */
75
- notify(eventName: "routeSync", payload: RouteSyncPayload): void
76
- notify(eventName: "navigate", payload: string): void
77
- }
71
+ export type { Api as StoreApi } from "@inglorious/store"
78
72
 
79
73
  /**
80
- * The router implementation.
74
+ * Client-side router for entity-based systems. Handles URL changes, link interception, and browser history management.
81
75
  */
82
- export declare const router: {
76
+ export interface RouterType {
83
77
  /**
84
78
  * Initializes the router, sets up a popstate listener to handle browser navigation,
85
79
  * and intercepts clicks on local links.
86
80
  * @param entity The router state entity.
87
81
  * @param payload The initialization payload (currently unused).
88
- * @param api The API for interacting with the host system.
82
+ * @param api The store API for interacting with the system.
89
83
  */
90
- init(entity: RouterEntity, payload: any, api: RouterApi): void
84
+ init(entity: RouterEntity, payload: any, api: StoreApi): void
91
85
 
92
86
  /**
93
- * Navigates to a new route programmatically.
94
- * @param entity The router state entity.
95
- * @param payload The navigation details.
96
- * @param api The API for interacting with the host system.
87
+ * Navigates to a new route, updating the browser's history and the router entity state.
88
+ * @param {RouterEntity} entity - The router entity.
89
+ * @param {string|number|NavigatePayload} payload - The navigation payload.
90
+ * Can be a path string, a number for `history.go()`, or an object with navigation options.
91
+ * @param {string|number} payload.to - The destination path or history offset.
92
+ * @param {RouteParams} [payload.params] - Route parameters to build the path from a pattern.
93
+ * @param {boolean} [payload.replace] - If true, uses `history.replaceState` instead of `pushState`.
94
+ * @param {Record<string, any>} [payload.state] - Additional state to store in the browser's history.
95
+ * @param {StoreApi} api - The store API.
97
96
  */
98
97
  navigate(
99
98
  entity: RouterEntity,
100
99
  payload: string | number | NavigatePayload,
101
- api: RouterApi,
100
+ api: StoreApi,
102
101
  ): void
103
102
 
104
103
  /**
105
- * Synchronizes the router entity's state with the provided route information.
106
- * Typically called in response to a `popstate` event.
107
- * @param entity The router state entity.
108
- * @param payload The new route information.
104
+ * Synchronizes the router entity's state with data from a routing event,
105
+ * typically triggered by a `popstate` event (browser back/forward).
106
+ * @param {RouterEntity} entity - The router entity to update.
107
+ * @param {RouteSyncPayload} payload - The new route state.
108
+ * @param {StoreApi} api - The store API.
109
+ */
110
+ routeSync(
111
+ entity: RouterEntity,
112
+ payload: RouteSyncPayload,
113
+ api: StoreApi,
114
+ ): void
115
+
116
+ /**
117
+ * Handles successful async route loading.
118
+ * @param {RouterEntity} entity - The router entity to update.
119
+ * @param {Object} payload - The load success payload.
120
+ * @param {StoreApi} api - The store API.
121
+ */
122
+ loadSuccess(entity: RouterEntity, payload: any, api: StoreApi): void
123
+
124
+ /**
125
+ * Handles async route loading errors.
126
+ * @param {RouterEntity} entity - The router entity.
127
+ * @param {Object} payload - The error payload.
109
128
  */
110
- routeSync(entity: RouterEntity, payload: RouteSyncPayload): void
129
+ loadError(entity: RouterEntity, payload: any): void
111
130
  }