@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/esm/components/lazy-load.spec.js +3 -3
  3. package/esm/components/lazy-load.spec.js.map +1 -1
  4. package/esm/components/nested-router.spec.js +24 -21
  5. package/esm/components/nested-router.spec.js.map +1 -1
  6. package/esm/components/router.spec.js +10 -9
  7. package/esm/components/router.spec.js.map +1 -1
  8. package/esm/services/resource-manager.d.ts +7 -1
  9. package/esm/services/resource-manager.d.ts.map +1 -1
  10. package/esm/services/resource-manager.js +26 -6
  11. package/esm/services/resource-manager.js.map +1 -1
  12. package/esm/services/resource-manager.spec.js +77 -0
  13. package/esm/services/resource-manager.spec.js.map +1 -1
  14. package/esm/shade.d.ts +2 -2
  15. package/esm/shade.d.ts.map +1 -1
  16. package/esm/shade.js +18 -4
  17. package/esm/shade.js.map +1 -1
  18. package/esm/shade.spec.js +5 -5
  19. package/esm/shade.spec.js.map +1 -1
  20. package/esm/shades.integration.spec.js +3 -7
  21. package/esm/shades.integration.spec.js.map +1 -1
  22. package/esm/vnode.integration.spec.js +0 -2
  23. package/esm/vnode.integration.spec.js.map +1 -1
  24. package/esm/vnode.js +1 -1
  25. package/esm/vnode.js.map +1 -1
  26. package/esm/vnode.spec.js +4 -5
  27. package/esm/vnode.spec.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/components/lazy-load.spec.tsx +3 -3
  30. package/src/components/nested-router.spec.tsx +24 -21
  31. package/src/components/router.spec.tsx +10 -9
  32. package/src/services/resource-manager.spec.ts +96 -0
  33. package/src/services/resource-manager.ts +29 -6
  34. package/src/shade.spec.tsx +5 -5
  35. package/src/shade.ts +19 -5
  36. package/src/shades.integration.spec.tsx +3 -7
  37. package/src/vnode.integration.spec.tsx +0 -2
  38. package/src/vnode.spec.ts +4 -5
  39. 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>(key: string, factory: () => T): T {
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
- if (!existing) {
21
- const created = factory()
22
- this.disposables.set(key, created)
23
- return created
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
- return existing as T
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
 
@@ -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 sleepAsync(10)
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 sleepAsync(50)
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 sleepAsync(50)
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 sleepAsync(50)
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.updateComponent()
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
- * Note: this flushes one level of pending updates. If a render itself triggers new
445
- * `updateComponent()` calls, an additional `await flushUpdates()` may be needed.
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 sleepAsync(100)
326
+ await flushUpdates()
327
327
 
328
328
  expectCount(0)
329
329
 
330
- await sleepAsync(100)
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 sleepAsync(100)
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 updateComponent on child Shade when props change', () => {
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>).updateComponent = updateFn
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 updateComponent when props are unchanged', () => {
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>).updateComponent = updateFn
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.updateComponent()
449
+ ;(el as unknown as { updateComponentSync: () => void }).updateComponentSync()
450
450
  }
451
451
  return
452
452
  }