@furystack/shades 12.2.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.
@@ -64,7 +64,7 @@ describe('Lazy Load', () => {
64
64
  })
65
65
  await flushUpdates()
66
66
  expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
67
- await sleepAsync(1)
67
+ await flushUpdates()
68
68
  expect(load).toBeCalledTimes(1)
69
69
  expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><button id="retry">:(</button></lazy-load></div>')
70
70
  document.getElementById('retry')?.click()
@@ -102,12 +102,12 @@ describe('Lazy Load', () => {
102
102
  })
103
103
  await flushUpdates()
104
104
  expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>Loading...</div></lazy-load></div>')
105
- await sleepAsync(1)
105
+ await flushUpdates()
106
106
  expect(load).toBeCalledTimes(1)
107
107
  expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><button id="retry">:(</button></lazy-load></div>')
108
108
  document.getElementById('retry')?.click()
109
109
  expect(load).toBeCalledTimes(2)
110
- await sleepAsync(1)
110
+ await flushUpdates()
111
111
  expect(document.body.innerHTML).toBe('<div id="root"><lazy-load><div>success</div></lazy-load></div>')
112
112
  })
113
113
  })
@@ -3,6 +3,7 @@ import { sleepAsync, usingAsync } from '@furystack/utils'
3
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
+ import { flushUpdates } from '../shade.js'
6
7
  import {
7
8
  buildMatchChain,
8
9
  findDivergenceIndex,
@@ -366,7 +367,7 @@ describe('NestedRouter lifecycle hooks', () => {
366
367
  const clickOn = (name: string) => document.getElementById(name)?.click()
367
368
 
368
369
  // --- Initial load at /parent/child-a ---
369
- await sleepAsync(100)
370
+ await flushUpdates()
370
371
  expect(getContent()).toBe('child-a')
371
372
  expect(onVisitParent).toBeCalledTimes(1)
372
373
  expect(onVisitChildA).toBeCalledTimes(1)
@@ -375,7 +376,7 @@ describe('NestedRouter lifecycle hooks', () => {
375
376
  // --- Click same route: no lifecycle hooks should fire ---
376
377
  callOrder.length = 0
377
378
  clickOn('child-a')
378
- await sleepAsync(100)
379
+ await flushUpdates()
379
380
  expect(onVisitParent).toBeCalledTimes(1)
380
381
  expect(onVisitChildA).toBeCalledTimes(1)
381
382
  expect(callOrder).toEqual([])
@@ -383,7 +384,7 @@ describe('NestedRouter lifecycle hooks', () => {
383
384
  // --- Switch child: only child lifecycle fires, parent stays ---
384
385
  callOrder.length = 0
385
386
  clickOn('child-b')
386
- await sleepAsync(100)
387
+ await flushUpdates()
387
388
  expect(getContent()).toBe('child-b')
388
389
  expect(onLeaveChildA).toBeCalledTimes(1)
389
390
  expect(onVisitChildB).toBeCalledTimes(1)
@@ -394,7 +395,8 @@ describe('NestedRouter lifecycle hooks', () => {
394
395
  // --- Navigate to a completely different branch ---
395
396
  callOrder.length = 0
396
397
  clickOn('other')
397
- await sleepAsync(100)
398
+ await flushUpdates()
399
+ await flushUpdates()
398
400
  expect(getContent()).toBe('other')
399
401
  expect(onLeaveChildB).toBeCalledTimes(1)
400
402
  expect(onLeaveParent).toBeCalledTimes(1)
@@ -405,7 +407,7 @@ describe('NestedRouter lifecycle hooks', () => {
405
407
  // --- Navigate to non-matching URL: onLeave for all active ---
406
408
  callOrder.length = 0
407
409
  clickOn('nowhere')
408
- await sleepAsync(100)
410
+ await flushUpdates()
409
411
  expect(getContent()).toBe('not found')
410
412
  expect(onLeaveOther).toBeCalledTimes(1)
411
413
  expect(callOrder).toEqual(['leave-other'])
@@ -490,7 +492,7 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
490
492
  const clickOn = (name: string) => document.getElementById(name)?.click()
491
493
 
492
494
  // --- Initial load at /route-a ---
493
- await sleepAsync(100)
495
+ await flushUpdates()
494
496
  expect(getContent()).toBe('route-a')
495
497
  expect(onVisitA).toHaveBeenCalledTimes(1)
496
498
 
@@ -500,7 +502,7 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
500
502
  // Don't await — immediately navigate again
501
503
  clickOn('go-c')
502
504
 
503
- // Wait long enough for both transitions to settle
505
+ // Wait long enough for both transitions to settle (onVisitB has 200ms delay)
504
506
  await sleepAsync(500)
505
507
 
506
508
  // The final destination should be route-c
@@ -582,7 +584,7 @@ describe('NestedRouter lifecycle element scope', () => {
582
584
  const clickOn = (name: string) => document.getElementById(name)?.click()
583
585
 
584
586
  // --- Initial load at /parent/child-a ---
585
- await sleepAsync(100)
587
+ await flushUpdates()
586
588
  expect(visitElements).toHaveLength(2)
587
589
  // Parent's onVisit element should be the full tree (parent wrapping child)
588
590
  expect(visitElements[0].route).toBe('parent')
@@ -595,7 +597,7 @@ describe('NestedRouter lifecycle element scope', () => {
595
597
  visitElements.length = 0
596
598
  leaveElements.length = 0
597
599
  clickOn('child-b')
598
- await sleepAsync(100)
600
+ await flushUpdates()
599
601
 
600
602
  // onLeave should receive the child-a element, not the full wrapper
601
603
  expect(leaveElements).toHaveLength(1)
@@ -653,19 +655,19 @@ describe('NestedRouter flat routes', () => {
653
655
  const getContent = () => document.getElementById('content')?.innerHTML
654
656
  const clickOn = (name: string) => document.getElementById(name)?.click()
655
657
 
656
- await sleepAsync(100)
658
+ await flushUpdates()
657
659
  expect(getContent()).toBe('home-page')
658
660
 
659
661
  clickOn('about')
660
- await sleepAsync(100)
662
+ await flushUpdates()
661
663
  expect(getContent()).toBe('about-page')
662
664
 
663
665
  clickOn('contact')
664
- await sleepAsync(100)
666
+ await flushUpdates()
665
667
  expect(getContent()).toBe('contact-page')
666
668
 
667
669
  clickOn('home')
668
- await sleepAsync(100)
670
+ await flushUpdates()
669
671
  expect(getContent()).toBe('home-page')
670
672
  })
671
673
  })
@@ -712,7 +714,7 @@ describe('NestedRouter outlet composition', () => {
712
714
  ),
713
715
  })
714
716
 
715
- await sleepAsync(100)
717
+ await flushUpdates()
716
718
 
717
719
  // Parent layout should be rendered with child inside
718
720
  expect(document.getElementById('header')?.innerHTML).toBe('Dashboard Header')
@@ -753,7 +755,7 @@ describe('NestedRouter outlet composition', () => {
753
755
  ),
754
756
  })
755
757
 
756
- await sleepAsync(100)
758
+ await flushUpdates()
757
759
 
758
760
  // Parent matched alone, outlet is undefined, so the fallback renders
759
761
  expect(document.getElementById('child')?.innerHTML).toBe('dashboard-index')
@@ -814,7 +816,7 @@ describe('NestedRouter route param changes', () => {
814
816
  const clickOn = (name: string) => document.getElementById(name)?.click()
815
817
 
816
818
  // Initial load at /users/1
817
- await sleepAsync(100)
819
+ await flushUpdates()
818
820
  expect(getContent()).toBe('user-1')
819
821
  expect(onVisitUser).toHaveBeenCalledTimes(1)
820
822
  expect(callOrder).toEqual(['visit-user'])
@@ -822,7 +824,7 @@ describe('NestedRouter route param changes', () => {
822
824
  // Navigate to /users/2 — same route, different param → lifecycle should fire
823
825
  callOrder.length = 0
824
826
  clickOn('user-2')
825
- await sleepAsync(100)
827
+ await flushUpdates()
826
828
  expect(getContent()).toBe('user-2')
827
829
  expect(onLeaveUser).toHaveBeenCalledTimes(1)
828
830
  expect(onVisitUser).toHaveBeenCalledTimes(2)
@@ -831,7 +833,7 @@ describe('NestedRouter route param changes', () => {
831
833
  // Navigate to /users/3
832
834
  callOrder.length = 0
833
835
  clickOn('user-3')
834
- await sleepAsync(100)
836
+ await flushUpdates()
835
837
  expect(getContent()).toBe('user-3')
836
838
  expect(onLeaveUser).toHaveBeenCalledTimes(2)
837
839
  expect(onVisitUser).toHaveBeenCalledTimes(3)
@@ -840,7 +842,7 @@ describe('NestedRouter route param changes', () => {
840
842
  // Click same user — no lifecycle change
841
843
  callOrder.length = 0
842
844
  clickOn('user-3')
843
- await sleepAsync(100)
845
+ await flushUpdates()
844
846
  expect(getContent()).toBe('user-3')
845
847
  expect(onLeaveUser).toHaveBeenCalledTimes(2)
846
848
  expect(onVisitUser).toHaveBeenCalledTimes(3)
@@ -897,7 +899,7 @@ describe('NestedRouter route param changes', () => {
897
899
 
898
900
  const clickOn = (name: string) => document.getElementById(name)?.click()
899
901
 
900
- await sleepAsync(100)
902
+ await flushUpdates()
901
903
  expect(document.getElementById('org')?.textContent).toContain('org-alpha')
902
904
  expect(document.getElementById('child')?.innerHTML).toBe('dashboard')
903
905
  expect(onVisitOrg).toHaveBeenCalledTimes(1)
@@ -906,7 +908,8 @@ describe('NestedRouter route param changes', () => {
906
908
  // Change parent param: org/alpha → org/beta, child stays /dashboard
907
909
  // Both parent and child should get leave/visit since parent diverges
908
910
  clickOn('beta-dash')
909
- await sleepAsync(100)
911
+ await flushUpdates()
912
+ await flushUpdates()
910
913
  expect(document.getElementById('org')?.textContent).toContain('org-beta')
911
914
  expect(document.getElementById('child')?.innerHTML).toBe('dashboard')
912
915
  expect(onLeaveOrg).toHaveBeenCalledTimes(1)
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import { initializeShadeRoot } from '../initialize.js'
5
5
  import { LocationService } from '../services/location-service.js'
6
6
  import { createComponent } from '../shade-component.js'
7
+ import { flushUpdates } from '../shade.js'
7
8
  import { RouteLink } from './route-link.js'
8
9
  import { Router, type Route } from './router.js'
9
10
 
@@ -81,7 +82,7 @@ describe('Router', () => {
81
82
  const clickOn = (name: string) => document.getElementById(name)?.click()
82
83
 
83
84
  // --- Initial load at /route-a ---
84
- await sleepAsync(100)
85
+ await flushUpdates()
85
86
  expect(getContent()).toBe('route-a')
86
87
  expect(onVisitA).toHaveBeenCalledTimes(1)
87
88
 
@@ -90,7 +91,7 @@ describe('Router', () => {
90
91
  clickOn('go-b')
91
92
  clickOn('go-c')
92
93
 
93
- // Wait long enough for both transitions to settle
94
+ // Wait long enough for both transitions to settle (onVisitB has 200ms delay)
94
95
  await sleepAsync(500)
95
96
 
96
97
  // The final destination should be route-c
@@ -169,7 +170,7 @@ describe('Router', () => {
169
170
 
170
171
  const clickOn = (name: string) => document.getElementById(name)?.click()
171
172
 
172
- await sleepAsync(100)
173
+ await flushUpdates()
173
174
 
174
175
  expect(getLocation()).toBe('/')
175
176
  expect(getContent()).toBe('home')
@@ -177,37 +178,37 @@ describe('Router', () => {
177
178
  expect(onVisit).not.toBeCalled()
178
179
 
179
180
  clickOn('a')
180
- await sleepAsync(100)
181
+ await flushUpdates()
181
182
  expect(getContent()).toBe('route-a')
182
183
  expect(getLocation()).toBe('/route-a')
183
184
  expect(onRouteChange).toBeCalledTimes(1)
184
185
  expect(onVisit).toBeCalledTimes(1)
185
186
 
186
187
  clickOn('a')
187
- await sleepAsync(100)
188
+ await flushUpdates()
188
189
  expect(onVisit).toBeCalledTimes(1)
189
190
  expect(onLeave).not.toBeCalled()
190
191
 
191
192
  clickOn('b')
192
- await sleepAsync(100)
193
+ await flushUpdates()
193
194
  expect(onLeave).toBeCalledTimes(1)
194
195
 
195
196
  expect(getContent()).toBe('route-b')
196
197
  expect(getLocation()).toBe('/route-b')
197
198
 
198
199
  clickOn('b-with-id')
199
- await sleepAsync(100)
200
+ await flushUpdates()
200
201
  expect(getContent()).toBe('route-b123')
201
202
  expect(getLocation()).toBe('/route-b/123')
202
203
 
203
204
  clickOn('c')
204
- await sleepAsync(100)
205
+ await flushUpdates()
205
206
  expect(getContent()).toBe('route-c')
206
207
  expect(getLocation()).toBe('/route-c')
207
208
 
208
209
  expect(onLastLeave).not.toBeCalled()
209
210
  clickOn('x')
210
- await sleepAsync(100)
211
+ await flushUpdates()
211
212
  expect(getContent()).toBe('not found')
212
213
  expect(getLocation()).toBe('/route-x')
213
214
  expect(onLastLeave).toBeCalledTimes(1)
@@ -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
  }