@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.
- package/CHANGELOG.md +46 -0
- package/esm/components/nested-router.d.ts +33 -0
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +3 -0
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +100 -18
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/services/index.d.ts +2 -0
- package/esm/services/index.d.ts.map +1 -1
- package/esm/services/index.js +2 -0
- package/esm/services/index.js.map +1 -1
- package/esm/services/route-match-service.d.ts +12 -0
- package/esm/services/route-match-service.d.ts.map +1 -0
- package/esm/services/route-match-service.js +24 -0
- package/esm/services/route-match-service.js.map +1 -0
- package/esm/services/route-match-service.spec.d.ts +2 -0
- package/esm/services/route-match-service.spec.d.ts.map +1 -0
- package/esm/services/route-match-service.spec.js +120 -0
- package/esm/services/route-match-service.spec.js.map +1 -0
- package/esm/services/route-meta-utils.d.ts +57 -0
- package/esm/services/route-meta-utils.d.ts.map +1 -0
- package/esm/services/route-meta-utils.js +64 -0
- package/esm/services/route-meta-utils.js.map +1 -0
- package/esm/services/route-meta-utils.spec.d.ts +2 -0
- package/esm/services/route-meta-utils.spec.d.ts.map +1 -0
- package/esm/services/route-meta-utils.spec.js +217 -0
- package/esm/services/route-meta-utils.spec.js.map +1 -0
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +12 -1
- package/esm/shade.js.map +1 -1
- package/esm/shade.spec.js +93 -2
- package/esm/shade.spec.js.map +1 -1
- package/package.json +1 -1
- package/src/components/nested-router.spec.tsx +140 -35
- package/src/components/nested-router.tsx +38 -0
- package/src/services/index.ts +2 -0
- package/src/services/route-match-service.spec.ts +45 -0
- package/src/services/route-match-service.ts +17 -0
- package/src/services/route-meta-utils.spec.ts +243 -0
- package/src/services/route-meta-utils.ts +85 -0
- package/src/shade.spec.tsx +112 -2
- 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
|
+
}
|
package/src/shade.spec.tsx
CHANGED
|
@@ -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
|
}
|