@furystack/shades 12.2.5 → 12.3.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 (42) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/esm/components/nested-router.d.ts +33 -0
  3. package/esm/components/nested-router.d.ts.map +1 -1
  4. package/esm/components/nested-router.js +3 -0
  5. package/esm/components/nested-router.js.map +1 -1
  6. package/esm/components/nested-router.spec.js +100 -18
  7. package/esm/components/nested-router.spec.js.map +1 -1
  8. package/esm/services/index.d.ts +2 -0
  9. package/esm/services/index.d.ts.map +1 -1
  10. package/esm/services/index.js +2 -0
  11. package/esm/services/index.js.map +1 -1
  12. package/esm/services/route-match-service.d.ts +12 -0
  13. package/esm/services/route-match-service.d.ts.map +1 -0
  14. package/esm/services/route-match-service.js +24 -0
  15. package/esm/services/route-match-service.js.map +1 -0
  16. package/esm/services/route-match-service.spec.d.ts +2 -0
  17. package/esm/services/route-match-service.spec.d.ts.map +1 -0
  18. package/esm/services/route-match-service.spec.js +120 -0
  19. package/esm/services/route-match-service.spec.js.map +1 -0
  20. package/esm/services/route-meta-utils.d.ts +57 -0
  21. package/esm/services/route-meta-utils.d.ts.map +1 -0
  22. package/esm/services/route-meta-utils.js +64 -0
  23. package/esm/services/route-meta-utils.js.map +1 -0
  24. package/esm/services/route-meta-utils.spec.d.ts +2 -0
  25. package/esm/services/route-meta-utils.spec.d.ts.map +1 -0
  26. package/esm/services/route-meta-utils.spec.js +217 -0
  27. package/esm/services/route-meta-utils.spec.js.map +1 -0
  28. package/esm/shade.d.ts.map +1 -1
  29. package/esm/shade.js +12 -1
  30. package/esm/shade.js.map +1 -1
  31. package/esm/shade.spec.js +93 -2
  32. package/esm/shade.spec.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/components/nested-router.spec.tsx +140 -35
  35. package/src/components/nested-router.tsx +38 -0
  36. package/src/services/index.ts +2 -0
  37. package/src/services/route-match-service.spec.ts +45 -0
  38. package/src/services/route-match-service.ts +17 -0
  39. package/src/services/route-meta-utils.spec.ts +243 -0
  40. package/src/services/route-meta-utils.ts +85 -0
  41. package/src/shade.spec.tsx +112 -2
  42. package/src/shade.ts +11 -1
@@ -0,0 +1,243 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { MatchChainEntry, NestedRoute } from '../components/nested-router.js'
4
+ import { buildDocumentTitle, extractNavTree, resolveRouteTitle, resolveRouteTitles } from './route-meta-utils.js'
5
+
6
+ describe('resolveRouteTitle', () => {
7
+ const injector = new Injector()
8
+
9
+ it('should return undefined when no meta is configured', async () => {
10
+ const entry: MatchChainEntry = {
11
+ route: { component: () => ({}) as JSX.Element },
12
+ match: { path: '/about', params: {} },
13
+ }
14
+ expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
15
+ })
16
+
17
+ it('should return undefined when meta has no title', async () => {
18
+ const entry: MatchChainEntry = {
19
+ route: { meta: {}, component: () => ({}) as JSX.Element },
20
+ match: { path: '/about', params: {} },
21
+ }
22
+ expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
23
+ })
24
+
25
+ it('should return a static string title', async () => {
26
+ const entry: MatchChainEntry = {
27
+ route: { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
28
+ match: { path: '/about', params: {} },
29
+ }
30
+ expect(await resolveRouteTitle(entry, injector)).toBe('About')
31
+ })
32
+
33
+ it('should resolve a synchronous function title', async () => {
34
+ const entry: MatchChainEntry = {
35
+ route: {
36
+ meta: { title: ({ match }) => `User ${(match.params as { id: string }).id}` },
37
+ component: () => ({}) as JSX.Element,
38
+ },
39
+ match: { path: '/users/42', params: { id: '42' } },
40
+ }
41
+ expect(await resolveRouteTitle(entry, injector)).toBe('User 42')
42
+ })
43
+
44
+ it('should resolve an async function title', async () => {
45
+ const entry: MatchChainEntry = {
46
+ route: {
47
+ meta: {
48
+ title: async ({ match }) => {
49
+ const { id } = match.params as { id: string }
50
+ return `Movie ${id}`
51
+ },
52
+ },
53
+ component: () => ({}) as JSX.Element,
54
+ },
55
+ match: { path: '/movies/7', params: { id: '7' } },
56
+ }
57
+ expect(await resolveRouteTitle(entry, injector)).toBe('Movie 7')
58
+ })
59
+
60
+ it('should pass the injector to the title resolver', async () => {
61
+ const entry: MatchChainEntry = {
62
+ route: {
63
+ meta: {
64
+ title: ({ injector: inj }) => (inj instanceof Injector ? 'has-injector' : 'no-injector'),
65
+ },
66
+ component: () => ({}) as JSX.Element,
67
+ },
68
+ match: { path: '/test', params: {} },
69
+ }
70
+ expect(await resolveRouteTitle(entry, injector)).toBe('has-injector')
71
+ })
72
+ })
73
+
74
+ describe('resolveRouteTitles', () => {
75
+ const injector = new Injector()
76
+
77
+ it('should resolve all titles in a mixed chain', async () => {
78
+ const chain: MatchChainEntry[] = [
79
+ {
80
+ route: { meta: { title: 'Media' }, component: () => ({}) as JSX.Element },
81
+ match: { path: '/media', params: {} },
82
+ },
83
+ {
84
+ route: { meta: { title: 'Movies' }, component: () => ({}) as JSX.Element },
85
+ match: { path: '/movies', params: {} },
86
+ },
87
+ {
88
+ route: {
89
+ meta: { title: async ({ match }) => `Movie ${(match.params as { id: string }).id}` },
90
+ component: () => ({}) as JSX.Element,
91
+ },
92
+ match: { path: '/7', params: { id: '7' } },
93
+ },
94
+ ]
95
+ const titles = await resolveRouteTitles(chain, injector)
96
+ expect(titles).toEqual(['Media', 'Movies', 'Movie 7'])
97
+ })
98
+
99
+ it('should return an empty array for an empty chain', async () => {
100
+ const titles = await resolveRouteTitles([], injector)
101
+ expect(titles).toEqual([])
102
+ })
103
+
104
+ it('should include undefined for entries without titles', async () => {
105
+ const chain: MatchChainEntry[] = [
106
+ {
107
+ route: { meta: { title: 'Root' }, component: () => ({}) as JSX.Element },
108
+ match: { path: '/', params: {} },
109
+ },
110
+ {
111
+ route: { component: () => ({}) as JSX.Element },
112
+ match: { path: '/child', params: {} },
113
+ },
114
+ ]
115
+ const titles = await resolveRouteTitles(chain, injector)
116
+ expect(titles).toEqual(['Root', undefined])
117
+ })
118
+ })
119
+
120
+ describe('buildDocumentTitle', () => {
121
+ it('should join titles with default separator', () => {
122
+ expect(buildDocumentTitle(['Media', 'Movies'])).toBe('Media - Movies')
123
+ })
124
+
125
+ it('should filter out undefined entries', () => {
126
+ expect(buildDocumentTitle(['Media', undefined, 'Movies'])).toBe('Media - Movies')
127
+ })
128
+
129
+ it('should use a custom separator', () => {
130
+ expect(buildDocumentTitle(['Media', 'Movies', 'Superman'], { separator: ' / ' })).toBe('Media / Movies / Superman')
131
+ })
132
+
133
+ it('should prepend a prefix', () => {
134
+ expect(buildDocumentTitle(['Media', 'Movies'], { prefix: 'My App' })).toBe('My App - Media - Movies')
135
+ })
136
+
137
+ it('should combine prefix and custom separator', () => {
138
+ expect(buildDocumentTitle(['Media', 'Movies', 'Superman'], { prefix: 'My App', separator: ' / ' })).toBe(
139
+ 'My App / Media / Movies / Superman',
140
+ )
141
+ })
142
+
143
+ it('should return empty string for empty titles', () => {
144
+ expect(buildDocumentTitle([])).toBe('')
145
+ })
146
+
147
+ it('should return only prefix when all titles are undefined', () => {
148
+ expect(buildDocumentTitle([undefined, undefined], { prefix: 'My App' })).toBe('My App')
149
+ })
150
+
151
+ it('should return prefix alone when titles is empty', () => {
152
+ expect(buildDocumentTitle([], { prefix: 'My App' })).toBe('My App')
153
+ })
154
+ })
155
+
156
+ describe('extractNavTree', () => {
157
+ it('should extract a flat route tree', () => {
158
+ const routes: Record<string, NestedRoute<unknown>> = {
159
+ '/about': { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
160
+ '/contact': { meta: { title: 'Contact' }, component: () => ({}) as JSX.Element },
161
+ }
162
+ const tree = extractNavTree(routes)
163
+ expect(tree).toEqual([
164
+ { pattern: '/about', fullPath: '/about', meta: { title: 'About' }, children: undefined },
165
+ { pattern: '/contact', fullPath: '/contact', meta: { title: 'Contact' }, children: undefined },
166
+ ])
167
+ })
168
+
169
+ it('should extract nested routes recursively', () => {
170
+ const routes: Record<string, NestedRoute<unknown>> = {
171
+ '/media': {
172
+ meta: { title: 'Media' },
173
+ component: () => ({}) as JSX.Element,
174
+ children: {
175
+ '/movies': { meta: { title: 'Movies' }, component: () => ({}) as JSX.Element },
176
+ '/music': { meta: { title: 'Music' }, component: () => ({}) as JSX.Element },
177
+ },
178
+ },
179
+ }
180
+ const tree = extractNavTree(routes)
181
+ expect(tree).toHaveLength(1)
182
+ expect(tree[0].pattern).toBe('/media')
183
+ expect(tree[0].fullPath).toBe('/media')
184
+ expect(tree[0].children).toHaveLength(2)
185
+ expect(tree[0].children![0]).toEqual({
186
+ pattern: '/movies',
187
+ fullPath: '/media/movies',
188
+ meta: { title: 'Movies' },
189
+ children: undefined,
190
+ })
191
+ expect(tree[0].children![1]).toEqual({
192
+ pattern: '/music',
193
+ fullPath: '/media/music',
194
+ meta: { title: 'Music' },
195
+ children: undefined,
196
+ })
197
+ })
198
+
199
+ it('should handle root "/" parent path correctly', () => {
200
+ const routes: Record<string, NestedRoute<unknown>> = {
201
+ '/': {
202
+ meta: { title: 'Home' },
203
+ component: () => ({}) as JSX.Element,
204
+ children: {
205
+ '/settings': { meta: { title: 'Settings' }, component: () => ({}) as JSX.Element },
206
+ },
207
+ },
208
+ }
209
+ const tree = extractNavTree(routes)
210
+ expect(tree[0].fullPath).toBe('/')
211
+ expect(tree[0].children![0].fullPath).toBe('/settings')
212
+ })
213
+
214
+ it('should compute correct fullPath for deeply nested routes (3+ levels)', () => {
215
+ const routes: Record<string, NestedRoute<unknown>> = {
216
+ '/a': {
217
+ meta: { title: 'A' },
218
+ component: () => ({}) as JSX.Element,
219
+ children: {
220
+ '/b': {
221
+ meta: { title: 'B' },
222
+ component: () => ({}) as JSX.Element,
223
+ children: {
224
+ '/c': { meta: { title: 'C' }, component: () => ({}) as JSX.Element },
225
+ },
226
+ },
227
+ },
228
+ },
229
+ }
230
+ const tree = extractNavTree(routes)
231
+ expect(tree[0].fullPath).toBe('/a')
232
+ expect(tree[0].children![0].fullPath).toBe('/a/b')
233
+ expect(tree[0].children![0].children![0].fullPath).toBe('/a/b/c')
234
+ })
235
+
236
+ it('should include routes without meta', () => {
237
+ const routes: Record<string, NestedRoute<unknown>> = {
238
+ '/hidden': { component: () => ({}) as JSX.Element },
239
+ }
240
+ const tree = extractNavTree(routes)
241
+ expect(tree[0].meta).toBeUndefined()
242
+ })
243
+ })
@@ -0,0 +1,85 @@
1
+ import type { Injector } from '@furystack/inject'
2
+
3
+ import type { MatchChainEntry, NestedRoute, NestedRouteMeta } from '../components/nested-router.js'
4
+
5
+ /**
6
+ * Resolves the title for a single match chain entry.
7
+ * If the title is a function, it is called with `{ match, injector }` (supports async).
8
+ * @param entry - A matched route entry from the match chain
9
+ * @param injector - The injector instance to pass to dynamic title resolvers
10
+ * @returns The resolved title string, or undefined if no title is configured
11
+ */
12
+ export const resolveRouteTitle = async (entry: MatchChainEntry, injector: Injector): Promise<string | undefined> => {
13
+ const title = entry.route.meta?.title
14
+ if (typeof title === 'function') return await title({ match: entry.match, injector })
15
+ return title
16
+ }
17
+
18
+ /**
19
+ * Resolves all titles from a match chain in parallel.
20
+ * @param chain - The match chain from outermost to innermost route
21
+ * @param injector - The injector instance to pass to dynamic title resolvers
22
+ * @returns An array of resolved titles (some may be undefined if no title is configured)
23
+ */
24
+ export const resolveRouteTitles = async (
25
+ chain: MatchChainEntry[],
26
+ injector: Injector,
27
+ ): Promise<Array<string | undefined>> => {
28
+ return Promise.all(chain.map((entry) => resolveRouteTitle(entry, injector)))
29
+ }
30
+
31
+ /**
32
+ * Composes resolved titles into a single document title string.
33
+ * Filters out undefined entries before joining.
34
+ * @param titles - Array of resolved titles (may contain undefined)
35
+ * @param options - Formatting options
36
+ * @param options.separator - String placed between title segments (default: `' - '`)
37
+ * @param options.prefix - Optional app name prepended before all segments
38
+ * @returns The composed title string
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * buildDocumentTitle(['Media', 'Movies', 'Superman'], { prefix: 'My App', separator: ' / ' })
43
+ * // => 'My App / Media / Movies / Superman'
44
+ *
45
+ * buildDocumentTitle(['Settings', undefined, 'Profile'])
46
+ * // => 'Settings - Profile'
47
+ * ```
48
+ */
49
+ export const buildDocumentTitle = (
50
+ titles: Array<string | undefined>,
51
+ options?: { separator?: string; prefix?: string },
52
+ ): string => {
53
+ const { separator = ' - ', prefix } = options ?? {}
54
+ const parts = titles.filter((t): t is string => t != null)
55
+ return prefix ? [prefix, ...parts].join(separator) : parts.join(separator)
56
+ }
57
+
58
+ /**
59
+ * A node in a navigation tree extracted from route definitions.
60
+ */
61
+ export type NavTreeNode = {
62
+ pattern: string
63
+ fullPath: string
64
+ meta?: NestedRouteMeta
65
+ children?: NavTreeNode[]
66
+ }
67
+
68
+ /**
69
+ * Extracts a navigation tree from route definitions.
70
+ * Useful for rendering sidebar navigation or sitemap-like structures.
71
+ * @param routes - The route definitions to extract from
72
+ * @param parentPath - The parent path prefix (used internally for recursion)
73
+ * @returns An array of navigation tree nodes
74
+ */
75
+ export const extractNavTree = (routes: Record<string, NestedRoute<unknown>>, parentPath?: string): NavTreeNode[] => {
76
+ return Object.entries(routes).map(([pattern, route]) => {
77
+ const fullPath = parentPath ? `${parentPath === '/' ? '' : parentPath}${pattern}` : pattern
78
+ return {
79
+ pattern,
80
+ fullPath,
81
+ meta: route.meta,
82
+ children: route.children ? extractNavTree(route.children, fullPath) : undefined,
83
+ }
84
+ })
85
+ }
@@ -1,6 +1,6 @@
1
1
  import { Injector } from '@furystack/inject'
2
- import { sleepAsync, usingAsync } from '@furystack/utils'
3
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { ObservableValue, sleepAsync, usingAsync } from '@furystack/utils'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import { initializeShadeRoot } from './initialize.js'
5
5
  import { createComponent } from './shade-component.js'
6
6
  import { flushUpdates, Shade } from './shade.js'
@@ -98,6 +98,116 @@ describe('Shade edge cases', () => {
98
98
  })
99
99
  })
100
100
 
101
+ describe('disconnected component should not re-render', () => {
102
+ it('should not re-render when updateComponent is called after removal', async () => {
103
+ await usingAsync(new Injector(), async (injector) => {
104
+ const rootElement = document.getElementById('root') as HTMLDivElement
105
+ const renderCounter = vi.fn()
106
+
107
+ const ExampleComponent = Shade({
108
+ shadowDomName: 'shade-no-render-after-disconnect',
109
+ render: () => {
110
+ renderCounter()
111
+ return <div>content</div>
112
+ },
113
+ })
114
+
115
+ initializeShadeRoot({
116
+ injector,
117
+ rootElement,
118
+ jsxElement: <ExampleComponent />,
119
+ })
120
+ await flushUpdates()
121
+
122
+ const element = document.querySelector('shade-no-render-after-disconnect') as JSX.Element
123
+ expect(element.getRenderCount()).toBe(1)
124
+ expect(renderCounter).toBeCalledTimes(1)
125
+
126
+ element.remove()
127
+ await flushUpdates()
128
+
129
+ element.updateComponent()
130
+ await flushUpdates()
131
+
132
+ expect(element.getRenderCount()).toBe(1)
133
+ expect(renderCounter).toBeCalledTimes(1)
134
+ })
135
+ })
136
+
137
+ it('should not re-render when updateComponentSync is called after removal', async () => {
138
+ await usingAsync(new Injector(), async (injector) => {
139
+ const rootElement = document.getElementById('root') as HTMLDivElement
140
+ const renderCounter = vi.fn()
141
+
142
+ const ExampleComponent = Shade({
143
+ shadowDomName: 'shade-no-sync-render-after-disconnect',
144
+ render: () => {
145
+ renderCounter()
146
+ return <div>content</div>
147
+ },
148
+ })
149
+
150
+ initializeShadeRoot({
151
+ injector,
152
+ rootElement,
153
+ jsxElement: <ExampleComponent />,
154
+ })
155
+ await flushUpdates()
156
+
157
+ const element = document.querySelector('shade-no-sync-render-after-disconnect') as JSX.Element
158
+ expect(element.getRenderCount()).toBe(1)
159
+ expect(renderCounter).toBeCalledTimes(1)
160
+
161
+ element.remove()
162
+ await flushUpdates()
163
+ ;(element as unknown as { updateComponentSync: () => void }).updateComponentSync()
164
+
165
+ expect(element.getRenderCount()).toBe(1)
166
+ expect(renderCounter).toBeCalledTimes(1)
167
+ })
168
+ })
169
+
170
+ it('should not re-render when an observable fires during disposal', async () => {
171
+ await usingAsync(new Injector(), async (injector) => {
172
+ const rootElement = document.getElementById('root') as HTMLDivElement
173
+ const renderCounter = vi.fn()
174
+ const obs = new ObservableValue(0)
175
+
176
+ const ExampleComponent = Shade({
177
+ shadowDomName: 'shade-no-render-during-disposal',
178
+ render: ({ useObservable, useDisposable }) => {
179
+ useObservable('obs', obs)
180
+ useDisposable('cleanup', () => ({
181
+ [Symbol.dispose]: () => {
182
+ obs.setValue(999)
183
+ },
184
+ }))
185
+ renderCounter()
186
+ return <div>{obs.getValue()}</div>
187
+ },
188
+ })
189
+
190
+ initializeShadeRoot({
191
+ injector,
192
+ rootElement,
193
+ jsxElement: <ExampleComponent />,
194
+ })
195
+ await flushUpdates()
196
+
197
+ const element = document.querySelector('shade-no-render-during-disposal') as JSX.Element
198
+ expect(element.getRenderCount()).toBe(1)
199
+ expect(renderCounter).toBeCalledTimes(1)
200
+
201
+ element.remove()
202
+ await flushUpdates()
203
+ await sleepAsync(10)
204
+
205
+ expect(element.getRenderCount()).toBe(1)
206
+ expect(renderCounter).toBeCalledTimes(1)
207
+ })
208
+ })
209
+ })
210
+
101
211
  describe('BroadcastChannel cross-tab communication', () => {
102
212
  it('should update stored state when receiving BroadcastChannel message with matching key', async () => {
103
213
  const mockedStorage = new Map<string, string>()
package/src/shade.ts CHANGED
@@ -110,11 +110,19 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
110
110
  */
111
111
  private _refs = new Map<string, RefObject<Element>>()
112
112
 
113
+ /**
114
+ * Set to true once disconnectedCallback fires. Prevents ghost re-renders
115
+ * triggered by observable changes during async disposal.
116
+ */
117
+ private _disconnected = false
118
+
113
119
  public connectedCallback() {
120
+ this._disconnected = false
114
121
  this._performUpdate()
115
122
  }
116
123
 
117
124
  public async disconnectedCallback() {
125
+ this._disconnected = true
118
126
  this._refs.clear()
119
127
  this._prevVTree = null
120
128
  this._prevHostProps = null
@@ -246,10 +254,11 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
246
254
  * runs are coalesced into a single render pass.
247
255
  */
248
256
  public updateComponent() {
257
+ if (this._disconnected) return
249
258
  if (!this._updateScheduled) {
250
259
  this._updateScheduled = true
251
260
  queueMicrotask(() => {
252
- if (!this._updateScheduled) return
261
+ if (!this._updateScheduled || this._disconnected) return
253
262
  this._updateScheduled = false
254
263
  this._performUpdate()
255
264
  })
@@ -262,6 +271,7 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
262
271
  * in a single call frame rather than cascading across microtask ticks.
263
272
  */
264
273
  public updateComponentSync() {
274
+ if (this._disconnected) return
265
275
  this._updateScheduled = false
266
276
  this._performUpdate()
267
277
  }