@furystack/shades 12.1.0 → 12.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/CHANGELOG.md +34 -0
- package/esm/components/lazy-load.spec.js +3 -3
- package/esm/components/lazy-load.spec.js.map +1 -1
- package/esm/components/nested-router.spec.js +24 -21
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/components/router.spec.js +10 -9
- package/esm/components/router.spec.js.map +1 -1
- package/esm/services/resource-manager.d.ts +7 -1
- package/esm/services/resource-manager.d.ts.map +1 -1
- package/esm/services/resource-manager.js +26 -6
- package/esm/services/resource-manager.js.map +1 -1
- package/esm/services/resource-manager.spec.js +77 -0
- package/esm/services/resource-manager.spec.js.map +1 -1
- package/esm/shade.d.ts +2 -2
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +18 -4
- package/esm/shade.js.map +1 -1
- package/esm/shade.spec.js +5 -5
- package/esm/shade.spec.js.map +1 -1
- package/esm/shades.integration.spec.js +3 -7
- package/esm/shades.integration.spec.js.map +1 -1
- package/esm/vnode.integration.spec.js +0 -2
- package/esm/vnode.integration.spec.js.map +1 -1
- package/esm/vnode.js +1 -1
- package/esm/vnode.js.map +1 -1
- package/esm/vnode.spec.js +4 -5
- package/esm/vnode.spec.js.map +1 -1
- package/package.json +1 -1
- package/src/components/lazy-load.spec.tsx +3 -3
- package/src/components/nested-router.spec.tsx +24 -21
- package/src/components/router.spec.tsx +10 -9
- package/src/services/resource-manager.spec.ts +96 -0
- package/src/services/resource-manager.ts +29 -6
- package/src/shade.spec.tsx +5 -5
- package/src/shade.ts +19 -5
- package/src/shades.integration.spec.tsx +3 -7
- package/src/vnode.integration.spec.tsx +0 -2
- package/src/vnode.spec.ts +4 -5
- package/src/vnode.ts +1 -1
|
@@ -7,22 +7,44 @@ import { ObservableValue, isAsyncDisposable, isDisposable } from '@furystack/uti
|
|
|
7
7
|
*/
|
|
8
8
|
export class ResourceManager implements AsyncDisposable {
|
|
9
9
|
private readonly disposables = new Map<string, Disposable | AsyncDisposable>()
|
|
10
|
+
private readonly disposableDeps = new Map<string, string>()
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Returns an existing disposable resource by key, or creates and caches a new one.
|
|
13
14
|
* Resources are automatically disposed when the component is removed from the DOM.
|
|
15
|
+
* When `deps` is provided, the resource is re-created (and the old one disposed) whenever
|
|
16
|
+
* the serialized deps value changes. This is useful for resources that depend on dynamic
|
|
17
|
+
* parameters (e.g., entity-sync subscriptions with changing query options).
|
|
14
18
|
* @param key Unique key for caching this resource
|
|
15
19
|
* @param factory Factory function called once to create the resource
|
|
20
|
+
* @param deps Optional dependency array -- when deps change, the old resource is disposed and a new one is created.
|
|
21
|
+
* Values are compared via `JSON.stringify`, so `undefined` and `null` are treated as equal within arrays.
|
|
16
22
|
* @returns The cached or newly created resource
|
|
17
23
|
*/
|
|
18
|
-
public useDisposable<T extends Disposable | AsyncDisposable>(
|
|
24
|
+
public useDisposable<T extends Disposable | AsyncDisposable>(
|
|
25
|
+
key: string,
|
|
26
|
+
factory: () => T,
|
|
27
|
+
deps?: readonly unknown[],
|
|
28
|
+
): T {
|
|
19
29
|
const existing = this.disposables.get(key)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
const depsKey = deps !== undefined ? JSON.stringify(deps) : undefined
|
|
31
|
+
|
|
32
|
+
if (existing) {
|
|
33
|
+
if (depsKey !== undefined && this.disposableDeps.get(key) !== depsKey) {
|
|
34
|
+
if (isDisposable(existing)) existing[Symbol.dispose]()
|
|
35
|
+
if (isAsyncDisposable(existing)) void existing[Symbol.asyncDispose]()
|
|
36
|
+
const created = factory()
|
|
37
|
+
this.disposables.set(key, created)
|
|
38
|
+
this.disposableDeps.set(key, depsKey)
|
|
39
|
+
return created
|
|
40
|
+
}
|
|
41
|
+
return existing as T
|
|
24
42
|
}
|
|
25
|
-
|
|
43
|
+
|
|
44
|
+
const created = factory()
|
|
45
|
+
this.disposables.set(key, created)
|
|
46
|
+
if (depsKey !== undefined) this.disposableDeps.set(key, depsKey)
|
|
47
|
+
return created
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
public readonly observers = new Map<string, ValueObserver<any>>()
|
|
@@ -108,6 +130,7 @@ export class ResourceManager implements AsyncDisposable {
|
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
this.disposables.clear()
|
|
133
|
+
this.disposableDeps.clear()
|
|
111
134
|
this.observers.forEach((r) => r[Symbol.dispose]())
|
|
112
135
|
this.observers.clear()
|
|
113
136
|
|
package/src/shade.spec.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { sleepAsync, usingAsync } from '@furystack/utils'
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
4
|
import { initializeShadeRoot } from './initialize.js'
|
|
5
5
|
import { createComponent } from './shade-component.js'
|
|
6
|
-
import { Shade } from './shade.js'
|
|
6
|
+
import { flushUpdates, Shade } from './shade.js'
|
|
7
7
|
|
|
8
8
|
describe('Shade edge cases', () => {
|
|
9
9
|
beforeEach(() => {
|
|
@@ -86,7 +86,7 @@ describe('Shade edge cases', () => {
|
|
|
86
86
|
),
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
await
|
|
89
|
+
await flushUpdates()
|
|
90
90
|
|
|
91
91
|
// Parent should use root injector (inherited from parent)
|
|
92
92
|
expect(parentCapturedInjector).toBe(rootInjector)
|
|
@@ -129,7 +129,7 @@ describe('Shade edge cases', () => {
|
|
|
129
129
|
jsxElement: <ExampleComponent />,
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
-
await
|
|
132
|
+
await flushUpdates()
|
|
133
133
|
expect(document.getElementById('value')?.textContent).toBe('initial')
|
|
134
134
|
|
|
135
135
|
// Simulate cross-tab message via BroadcastChannel
|
|
@@ -173,7 +173,7 @@ describe('Shade edge cases', () => {
|
|
|
173
173
|
jsxElement: <ExampleComponent />,
|
|
174
174
|
})
|
|
175
175
|
|
|
176
|
-
await
|
|
176
|
+
await flushUpdates()
|
|
177
177
|
expect(document.getElementById('value')?.textContent).toBe('initial')
|
|
178
178
|
|
|
179
179
|
// Simulate cross-tab message with different key
|
|
@@ -218,7 +218,7 @@ describe('Shade edge cases', () => {
|
|
|
218
218
|
jsxElement: <ExampleComponent />,
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
-
await
|
|
221
|
+
await flushUpdates()
|
|
222
222
|
expect(document.getElementById('value')?.textContent).toBe('initial')
|
|
223
223
|
|
|
224
224
|
// Remove the component from DOM
|
package/src/shade.ts
CHANGED
|
@@ -111,7 +111,7 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
|
|
|
111
111
|
private _refs = new Map<string, RefObject<Element>>()
|
|
112
112
|
|
|
113
113
|
public connectedCallback() {
|
|
114
|
-
this.
|
|
114
|
+
this._performUpdate()
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
public async disconnectedCallback() {
|
|
@@ -249,12 +249,23 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
|
|
|
249
249
|
if (!this._updateScheduled) {
|
|
250
250
|
this._updateScheduled = true
|
|
251
251
|
queueMicrotask(() => {
|
|
252
|
+
if (!this._updateScheduled) return
|
|
252
253
|
this._updateScheduled = false
|
|
253
254
|
this._performUpdate()
|
|
254
255
|
})
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Performs a synchronous component update, canceling any pending async update.
|
|
261
|
+
* Used during parent-to-child reconciliation so the entire subtree settles
|
|
262
|
+
* in a single call frame rather than cascading across microtask ticks.
|
|
263
|
+
*/
|
|
264
|
+
public updateComponentSync() {
|
|
265
|
+
this._updateScheduled = false
|
|
266
|
+
this._performUpdate()
|
|
267
|
+
}
|
|
268
|
+
|
|
258
269
|
private _performUpdate() {
|
|
259
270
|
this._pendingHostProps = []
|
|
260
271
|
let renderResult: unknown
|
|
@@ -265,11 +276,14 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
|
|
|
265
276
|
setRenderMode(false)
|
|
266
277
|
}
|
|
267
278
|
|
|
279
|
+
// Apply host props before patching children so that child components
|
|
280
|
+
// rendered synchronously can discover parent state (e.g. injector)
|
|
281
|
+
// via getInjectorFromParent().
|
|
282
|
+
this._applyHostProps()
|
|
283
|
+
|
|
268
284
|
const newVTree = toVChildArray(renderResult)
|
|
269
285
|
patchChildren(this, this._prevVTree || [], newVTree)
|
|
270
286
|
this._prevVTree = newVTree
|
|
271
|
-
|
|
272
|
-
this._applyHostProps()
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
/**
|
|
@@ -441,8 +455,8 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
|
|
|
441
455
|
* Flushes any pending microtask-based component updates.
|
|
442
456
|
* Useful in tests to wait for batched renders to complete before asserting DOM state.
|
|
443
457
|
*
|
|
444
|
-
*
|
|
445
|
-
* `
|
|
458
|
+
* Child component updates during reconciliation are performed synchronously, so a single
|
|
459
|
+
* `await flushUpdates()` is sufficient to settle the entire component tree after a state change.
|
|
446
460
|
* @returns a promise that resolves after the current microtask queue has been processed
|
|
447
461
|
*/
|
|
448
462
|
export const flushUpdates = (): Promise<void> => new Promise<void>((resolve) => queueMicrotask(resolve))
|
|
@@ -323,11 +323,11 @@ describe('Shades integration tests', () => {
|
|
|
323
323
|
}
|
|
324
324
|
const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`)
|
|
325
325
|
|
|
326
|
-
await
|
|
326
|
+
await flushUpdates()
|
|
327
327
|
|
|
328
328
|
expectCount(0)
|
|
329
329
|
|
|
330
|
-
await
|
|
330
|
+
await flushUpdates()
|
|
331
331
|
await plus()
|
|
332
332
|
expectCount(1)
|
|
333
333
|
expect(store.getItem('count')).toBe('1')
|
|
@@ -383,7 +383,7 @@ describe('Shades integration tests', () => {
|
|
|
383
383
|
|
|
384
384
|
expectCount(0)
|
|
385
385
|
|
|
386
|
-
await
|
|
386
|
+
await flushUpdates()
|
|
387
387
|
await plus()
|
|
388
388
|
expectCount(1)
|
|
389
389
|
expect(location.search).toBe(`?${serializeToQueryString({ count: 1 })}`)
|
|
@@ -520,7 +520,6 @@ describe('Shades integration tests', () => {
|
|
|
520
520
|
jsxElement: <ParentComponent />,
|
|
521
521
|
})
|
|
522
522
|
|
|
523
|
-
await flushUpdates()
|
|
524
523
|
await flushUpdates()
|
|
525
524
|
|
|
526
525
|
const innerInput = document.querySelector('[data-testid="inner-input"]') as HTMLInputElement
|
|
@@ -531,9 +530,6 @@ describe('Shades integration tests', () => {
|
|
|
531
530
|
const toggleBtn = document.getElementById('toggle-disabled') as HTMLButtonElement
|
|
532
531
|
toggleBtn.click()
|
|
533
532
|
|
|
534
|
-
// Wait for parent re-render + child re-render (two microtask levels)
|
|
535
|
-
await flushUpdates()
|
|
536
|
-
await flushUpdates()
|
|
537
533
|
await flushUpdates()
|
|
538
534
|
|
|
539
535
|
const updatedInput = document.querySelector('[data-testid="inner-input"]') as HTMLInputElement
|
|
@@ -637,7 +637,6 @@ describe('VNode reconciliation integration tests', () => {
|
|
|
637
637
|
jsxElement: <ParentComponent />,
|
|
638
638
|
})
|
|
639
639
|
await flushUpdates()
|
|
640
|
-
await flushUpdates()
|
|
641
640
|
|
|
642
641
|
expect(document.getElementById('child-value')?.textContent).toBe('0')
|
|
643
642
|
const childElement = document.querySelector('morph-child-component')
|
|
@@ -645,7 +644,6 @@ describe('VNode reconciliation integration tests', () => {
|
|
|
645
644
|
// Trigger parent re-render
|
|
646
645
|
document.getElementById('parent-increment')?.click()
|
|
647
646
|
await flushUpdates()
|
|
648
|
-
await flushUpdates()
|
|
649
647
|
|
|
650
648
|
// Child should be the same DOM element (not recreated)
|
|
651
649
|
expect(document.querySelector('morph-child-component')).toBe(childElement)
|
package/src/vnode.spec.ts
CHANGED
|
@@ -532,19 +532,18 @@ describe('vnode', () => {
|
|
|
532
532
|
})
|
|
533
533
|
|
|
534
534
|
describe('Shade component boundaries', () => {
|
|
535
|
-
it('should call
|
|
535
|
+
it('should call updateComponentSync on child Shade when props change', () => {
|
|
536
536
|
const parent = document.createElement('div')
|
|
537
537
|
|
|
538
538
|
const fakeShadeEl = document.createElement('my-shade') as unknown as JSX.Element
|
|
539
539
|
const updateFn = vi.fn()
|
|
540
|
-
;(fakeShadeEl as unknown as Record<string, unknown>).
|
|
540
|
+
;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
|
|
541
541
|
;(fakeShadeEl as unknown as Record<string, unknown>).props = { count: 1 }
|
|
542
542
|
;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
|
|
543
543
|
|
|
544
544
|
const factory = vi.fn(() => fakeShadeEl as unknown as JSX.Element)
|
|
545
545
|
|
|
546
546
|
const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]
|
|
547
|
-
// Simulate initial mount by manually appending
|
|
548
547
|
parent.appendChild(fakeShadeEl)
|
|
549
548
|
|
|
550
549
|
const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 2 }, children: [] }]
|
|
@@ -554,13 +553,13 @@ describe('vnode', () => {
|
|
|
554
553
|
expect(fakeShadeEl.props).toEqual({ count: 2 })
|
|
555
554
|
})
|
|
556
555
|
|
|
557
|
-
it('should NOT call
|
|
556
|
+
it('should NOT call updateComponentSync when props are unchanged', () => {
|
|
558
557
|
const parent = document.createElement('div')
|
|
559
558
|
|
|
560
559
|
const fakeShadeEl = document.createElement('my-shade-2') as unknown as JSX.Element
|
|
561
560
|
const updateFn = vi.fn()
|
|
562
561
|
const props = { count: 1 }
|
|
563
|
-
;(fakeShadeEl as unknown as Record<string, unknown>).
|
|
562
|
+
;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn
|
|
564
563
|
;(fakeShadeEl as unknown as Record<string, unknown>).props = props
|
|
565
564
|
;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined
|
|
566
565
|
|
package/src/vnode.ts
CHANGED
|
@@ -446,7 +446,7 @@ const patchChild = (_parentEl: Node, oldChild: VChild, newChild: VChild): void =
|
|
|
446
446
|
patchProps(el, oldChild.props, newChild.props)
|
|
447
447
|
}
|
|
448
448
|
el.shadeChildren = newChild.children as unknown as ChildrenList
|
|
449
|
-
el.
|
|
449
|
+
;(el as unknown as { updateComponentSync: () => void }).updateComponentSync()
|
|
450
450
|
}
|
|
451
451
|
return
|
|
452
452
|
}
|