@furystack/shades 6.1.5 → 7.0.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 (67) hide show
  1. package/dist/component-factory.spec.js +6 -6
  2. package/dist/component-factory.spec.js.map +1 -1
  3. package/dist/components/lazy-load.js +17 -14
  4. package/dist/components/lazy-load.js.map +1 -1
  5. package/dist/components/route-link.js +7 -3
  6. package/dist/components/route-link.js.map +1 -1
  7. package/dist/components/route-link.spec.js +2 -2
  8. package/dist/components/route-link.spec.js.map +1 -1
  9. package/dist/components/router.js +25 -26
  10. package/dist/components/router.js.map +1 -1
  11. package/dist/components/router.spec.js +2 -2
  12. package/dist/components/router.spec.js.map +1 -1
  13. package/dist/models/render-options.js +23 -0
  14. package/dist/models/render-options.js.map +1 -1
  15. package/dist/services/location-service.js +16 -4
  16. package/dist/services/location-service.js.map +1 -1
  17. package/dist/services/location-service.spec.js +1 -1
  18. package/dist/services/location-service.spec.js.map +1 -1
  19. package/dist/services/resource-manager.js +48 -0
  20. package/dist/services/resource-manager.js.map +1 -0
  21. package/dist/services/resource-manager.spec.js +32 -0
  22. package/dist/services/resource-manager.spec.js.map +1 -0
  23. package/dist/shade-component.js +28 -14
  24. package/dist/shade-component.js.map +1 -1
  25. package/dist/shade-resources.integration.spec.js +7 -8
  26. package/dist/shade-resources.integration.spec.js.map +1 -1
  27. package/dist/shade.js +33 -69
  28. package/dist/shade.js.map +1 -1
  29. package/dist/shades.integration.spec.js +197 -187
  30. package/dist/shades.integration.spec.js.map +1 -1
  31. package/package.json +5 -5
  32. package/src/component-factory.spec.tsx +7 -7
  33. package/src/components/lazy-load.tsx +19 -15
  34. package/src/components/route-link.spec.tsx +2 -2
  35. package/src/components/route-link.tsx +11 -10
  36. package/src/components/router.spec.tsx +2 -2
  37. package/src/components/router.tsx +32 -32
  38. package/src/jsx.ts +3 -2
  39. package/src/models/render-options.ts +37 -9
  40. package/src/services/location-service.spec.ts +1 -1
  41. package/src/services/location-service.tsx +18 -4
  42. package/src/services/resource-manager.spec.ts +33 -0
  43. package/src/services/resource-manager.ts +60 -0
  44. package/src/shade-component.ts +35 -15
  45. package/src/shade-resources.integration.spec.tsx +8 -14
  46. package/src/shade.ts +34 -107
  47. package/src/shades.integration.spec.tsx +265 -252
  48. package/types/components/lazy-load.d.ts +1 -1
  49. package/types/components/lazy-load.d.ts.map +1 -1
  50. package/types/components/route-link.d.ts +1 -1
  51. package/types/components/route-link.d.ts.map +1 -1
  52. package/types/components/router.d.ts +6 -8
  53. package/types/components/router.d.ts.map +1 -1
  54. package/types/jsx.d.ts +3 -2
  55. package/types/jsx.d.ts.map +1 -1
  56. package/types/models/render-options.d.ts +7 -7
  57. package/types/models/render-options.d.ts.map +1 -1
  58. package/types/services/location-service.d.ts +12 -1
  59. package/types/services/location-service.d.ts.map +1 -1
  60. package/types/services/resource-manager.d.ts +16 -0
  61. package/types/services/resource-manager.d.ts.map +1 -0
  62. package/types/services/resource-manager.spec.d.ts +2 -0
  63. package/types/services/resource-manager.spec.d.ts.map +1 -0
  64. package/types/shade-component.d.ts +16 -5
  65. package/types/shade-component.d.ts.map +1 -1
  66. package/types/shade.d.ts +8 -27
  67. package/types/shade.d.ts.map +1 -1
@@ -1,22 +1,23 @@
1
1
  import { Shade } from '../shade'
2
2
  import type { PartialElement } from '../models'
3
3
  import { LocationService } from '../services'
4
- import { createComponent } from '..'
4
+ import { attachProps, createComponent } from '..'
5
5
 
6
6
  export type RouteLinkProps = PartialElement<HTMLAnchorElement>
7
7
 
8
8
  export const RouteLink = Shade<RouteLinkProps>({
9
9
  shadowDomName: 'route-link',
10
- render: ({ children, props, injector }) => {
10
+ render: ({ children, props, injector, element }) => {
11
+ attachProps(element, {
12
+ ...props,
13
+ onclick: (ev: MouseEvent) => {
14
+ ev.preventDefault()
15
+ history.pushState('', props.title || '', props.href)
16
+ injector.getInstance(LocationService).updateState()
17
+ },
18
+ })
11
19
  return (
12
- <a
13
- {...props}
14
- onclick={(ev: MouseEvent) => {
15
- ev.preventDefault()
16
- history.pushState('', props.title || '', props.href)
17
- injector.getInstance(LocationService).updateState()
18
- }}
19
- >
20
+ <a href={props.href} style={{ color: 'inherit', textDecoration: 'inherit' }} onclick={(e) => e.preventDefault()}>
20
21
  {children}
21
22
  </a>
22
23
  )
@@ -25,7 +25,7 @@ describe('Router', () => {
25
25
 
26
26
  const onRouteChange = jest.fn()
27
27
 
28
- injector.getInstance(LocationService).onLocationChanged.subscribe(onRouteChange)
28
+ injector.getInstance(LocationService).onLocationPathChanged.subscribe(onRouteChange)
29
29
 
30
30
  initializeShadeRoot({
31
31
  injector,
@@ -61,7 +61,7 @@ describe('Router', () => {
61
61
  },
62
62
  { url: '/', component: () => <div id="content">home</div> },
63
63
  ]}
64
- notFound={() => <div id="content">not found</div>}
64
+ notFound={<div id="content">not found</div>}
65
65
  />
66
66
  </div>
67
67
  ),
@@ -11,67 +11,67 @@ export interface Route<TMatchResult extends object> {
11
11
  url: string
12
12
  component: (options: { currentUrl: string; match: MatchResult<TMatchResult> }) => JSX.Element
13
13
  routingOptions?: TokensToRegexpOptions
14
- onVisit?: (options: RenderOptions<RouterProps, RouterState>) => Promise<void>
15
- onLeave?: (options: RenderOptions<RouterProps, RouterState>) => Promise<void>
14
+ onVisit?: (options: RenderOptions<unknown>) => Promise<void>
15
+ onLeave?: (options: RenderOptions<unknown>) => Promise<void>
16
16
  }
17
17
 
18
18
  export interface RouterProps {
19
19
  style?: CSSStyleDeclaration
20
20
  routes: Array<Route<any>>
21
- notFound?: (currentUrl: string) => JSX.Element
21
+ notFound?: JSX.Element
22
22
  }
23
23
 
24
24
  export interface RouterState {
25
- activeRoute?: Route<any>
25
+ activeRoute?: Route<any> | null
26
26
  activeRouteParams?: any
27
- jsx?: JSX.Element
28
- lock: Semaphore
27
+ jsx: JSX.Element
29
28
  }
30
- export const Router = Shade<RouterProps, RouterState>({
29
+ export const Router = Shade<RouterProps>({
31
30
  shadowDomName: 'shade-router',
32
- getInitialState: () => ({
33
- lock: new Semaphore(1),
34
- }),
35
- resources: ({ children, props, injector, updateState, getState, element }) => [
36
- injector.getInstance(LocationService).onLocationChanged.subscribe(async (currentUrl) => {
37
- const { activeRoute: lastRoute, activeRouteParams: lastParams, lock } = getState()
31
+ render: (options) => {
32
+ const { useState, useObservable, injector } = options
33
+ const [lock] = useState('lock', new Semaphore(1))
34
+ const [state, setState] = useState<RouterState>('routerState', {
35
+ jsx: <div />,
36
+ })
37
+
38
+ const updateUrl = async (currentUrl: string) => {
39
+ const [lastState] = useState<RouterState>('routerState', state)
40
+ const { activeRoute: lastRoute, activeRouteParams: lastRouteParams, jsx: lastJsx } = lastState
38
41
  try {
39
42
  await lock.acquire()
40
- for (const route of props.routes) {
43
+ for (const route of options.props.routes) {
41
44
  const matchFn = match(route.url, route.routingOptions)
42
45
  const matchResult = matchFn(currentUrl)
43
46
  if (matchResult) {
44
- if (route !== lastRoute || JSON.stringify(lastParams) !== JSON.stringify(matchResult.params)) {
45
- await lastRoute?.onLeave?.({ children, props, injector, updateState, getState, element })
46
- updateState({
47
- jsx: route.component({ currentUrl, match: matchResult }),
48
- activeRoute: route,
49
- activeRouteParams: matchResult.params,
50
- })
51
- await route.onVisit?.({ children, props, injector, updateState, getState, element })
47
+ if (route !== lastRoute || JSON.stringify(lastRouteParams) !== JSON.stringify(matchResult.params)) {
48
+ await lastRoute?.onLeave?.({ ...options, element: lastState.jsx })
49
+ const newJsx = route.component({ currentUrl, match: matchResult })
50
+ setState({ jsx: newJsx, activeRoute: route, activeRouteParams: matchResult.params })
51
+ await route.onVisit?.({ ...options, element: newJsx })
52
52
  }
53
53
  return
54
54
  }
55
55
  }
56
56
  if (lastRoute?.onLeave) {
57
- await lastRoute.onLeave({ children, props, injector, updateState, getState, element })
57
+ await lastRoute.onLeave({ ...options, element: lastJsx })
58
58
  }
59
- updateState({ jsx: props.notFound?.(currentUrl), activeRoute: undefined })
59
+ setState({
60
+ jsx: options.props.notFound || <div />,
61
+ activeRoute: null,
62
+ activeRouteParams: null,
63
+ })
60
64
  } catch (e) {
61
65
  // path updates can be async, this can be ignored
62
66
  if (!(e instanceof ObservableAlreadyDisposedError)) {
63
67
  throw e
64
68
  }
65
69
  } finally {
66
- lock.release()
70
+ lock?.release()
67
71
  }
68
- }, true),
69
- ],
70
- render: ({ getState }) => {
71
- const { jsx } = getState()
72
- if (jsx) {
73
- return jsx
74
72
  }
75
- return <div></div>
73
+
74
+ useObservable('locationPathChanged', injector.getInstance(LocationService).onLocationPathChanged, updateUrl, true)
75
+ return state.jsx
76
76
  },
77
77
  })
package/src/jsx.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
  import type { ChildrenList, PartialElement } from './models'
3
+ import type { ResourceManager } from './services/resource-manager'
3
4
 
4
5
  declare global {
5
6
  // eslint-disable-next-line @typescript-eslint/no-namespace
6
7
  export namespace JSX {
7
- interface Element<TProps = any, TState = any> extends HTMLElement {
8
+ interface Element<TProps = any> extends HTMLElement {
8
9
  injector: Injector
9
- state: TState
10
10
  props: TProps
11
11
  updateComponent: () => void
12
12
  shadeChildren?: ChildrenList
13
13
  callConstructed: () => void
14
+ resourceManager: ResourceManager
14
15
  }
15
16
 
16
17
  interface IntrinsicElements {
@@ -1,16 +1,44 @@
1
1
  import type { Injector } from '@furystack/inject'
2
- import type { PartialElement } from './partial-element'
3
2
  import type { ChildrenList } from './children-list'
3
+ import type { Disposable, ObservableValue } from '@furystack/utils'
4
4
 
5
- export type RenderOptions<TProps, TState> = {
5
+ export type RenderOptions<TProps> = {
6
6
  readonly props: TProps
7
7
 
8
8
  injector: Injector
9
9
  children?: ChildrenList
10
- element: JSX.Element<TProps, TState>
11
- } & (unknown extends TState
12
- ? {}
13
- : {
14
- getState: () => TState
15
- updateState: (newState: PartialElement<TState>, skipRender?: boolean) => void
16
- })
10
+ element: JSX.Element<TProps>
11
+ useDisposable: <T extends Disposable>(key: string, factory: () => T) => T
12
+ useObservable: <T>(
13
+ key: string,
14
+ observable: ObservableValue<T>,
15
+ callback?: (newValue: T) => void,
16
+ getLast?: boolean,
17
+ ) => [value: T, setValue: (newValue: T) => void]
18
+ useState: <T>(key: string, initialValue: T) => [value: T, setValue: (newValue: T) => void]
19
+ }
20
+
21
+ // export type RenderOptionsState<TState> = unknown extends TState
22
+ // ? {}
23
+ // : {
24
+ // /**
25
+ // * @returns the current state object
26
+ // */
27
+ // getState: () => TState
28
+ // /**
29
+ // * Update the current component state's multiple properties in one-shot
30
+ // *
31
+ // * @param newState The partial new state object
32
+ // * @param skipRender Option to skip the render process
33
+ // */
34
+ // updateState: (newState: PartialElement<TState>, skipRender?: boolean) => void
35
+ // /**
36
+ // * @param key The key on the state object
37
+ // * @returns A tuple with the value and the setter function
38
+ // */
39
+ // useState: <T extends keyof TState>(
40
+ // key: T,
41
+ // ) => [value: TState[T], setValue: (newValue: TState[T], skipRender?: boolean) => void]
42
+ // }
43
+
44
+ // export type RenderOptions<TProps, TState> = RenderOptionsBase<TProps, TState> & RenderOptionsState<TState>
@@ -22,7 +22,7 @@ describe('LocationService', () => {
22
22
  await usingAsync(new Injector(), async (i) => {
23
23
  const onLocaionChanged = jest.fn()
24
24
  const s = i.getInstance(LocationService)
25
- s.onLocationChanged.subscribe(onLocaionChanged)
25
+ s.onLocationPathChanged.subscribe(onLocaionChanged)
26
26
  expect(onLocaionChanged).toBeCalledTimes(0)
27
27
  history.pushState(null, '', '/loc1')
28
28
  expect(onLocaionChanged).toBeCalledTimes(1)
@@ -8,14 +8,28 @@ export class LocationService implements Disposable {
8
8
  window.removeEventListener('hashchange', this.updateState)
9
9
  this.pushStateTracer.dispose()
10
10
  this.replaceStateTracer.dispose()
11
- this.onLocationChanged.dispose()
11
+ this.onLocationPathChanged.dispose()
12
12
  }
13
13
 
14
- public onLocationChanged = new ObservableValue(new URL(location.href).pathname)
14
+ /**
15
+ * Observable value that will be updated when the location pathname (e.g. /page/1) changes
16
+ */
17
+ public onLocationPathChanged = new ObservableValue(new URL(location.href).pathname)
18
+
19
+ /**
20
+ * Observable value that will be updated when the location hash (e.g. #hash) changes
21
+ */
22
+ public onLocationHashChanged = new ObservableValue(location.hash)
23
+
24
+ /**
25
+ * Observable value that will be updated when the location search (e.g. ?search=1) changes
26
+ */
27
+ public onLocationSearchChanged = new ObservableValue(location.search)
15
28
 
16
29
  public updateState() {
17
- const newUrl = new URL(location.href)
18
- this.onLocationChanged.setValue(newUrl.pathname)
30
+ this.onLocationPathChanged.setValue(location.pathname)
31
+ this.onLocationHashChanged.setValue(location.hash)
32
+ this.onLocationSearchChanged.setValue(location.search)
19
33
  }
20
34
 
21
35
  private pushStateTracer: Disposable
@@ -0,0 +1,33 @@
1
+ import { ObservableValue, using } from '@furystack/utils'
2
+ import { ResourceManager } from './resource-manager'
3
+ describe('ResourceManager', () => {
4
+ it('Should return an observable from cache', () => {
5
+ using(new ResourceManager(), (rm) => {
6
+ const o = new ObservableValue(1)
7
+ const [value1] = rm.useObservable('test', o, () => {
8
+ /** ignore */
9
+ })
10
+ const [value2] = rm.useObservable('test', o, () => {
11
+ /** ignore */
12
+ })
13
+
14
+ expect(value1).toBe(value2)
15
+
16
+ expect(o.getObservers().length).toBe(1)
17
+ })
18
+ })
19
+
20
+ it('Should return a disposable from cache', () => {
21
+ using(new ResourceManager(), (rm) => {
22
+ const d = {
23
+ dispose: () => {
24
+ /** ignore */
25
+ },
26
+ }
27
+ const d1 = rm.useDisposable('test', () => d)
28
+ const d2 = rm.useDisposable('test', () => d)
29
+
30
+ expect(d1).toBe(d2)
31
+ })
32
+ })
33
+ })
@@ -0,0 +1,60 @@
1
+ import { ObservableValue } from '@furystack/utils'
2
+ import type { Disposable, ValueChangeCallback } from '@furystack/utils'
3
+ import type { ValueObserver } from '@furystack/utils'
4
+
5
+ /**
6
+ * Class for managing observables and disposables for components, based on key-value maps
7
+ */
8
+ export class ResourceManager {
9
+ private readonly disposables = new Map<string, Disposable>()
10
+
11
+ public useDisposable<T extends Disposable>(key: string, factory: () => T): T {
12
+ if (!this.disposables.has(key)) {
13
+ this.disposables.set(key, factory())
14
+ }
15
+ return this.disposables.get(key) as T
16
+ }
17
+
18
+ public readonly observers = new Map<string, ValueObserver<any>>()
19
+
20
+ public useObservable = <T>(
21
+ key: string,
22
+ observable: ObservableValue<T>,
23
+ callback: ValueChangeCallback<T>,
24
+ getLast?: boolean,
25
+ ): [value: T, setValue: (newValue: T) => void] => {
26
+ const alreadyUsed = this.observers.get(key) as ValueObserver<T> | undefined
27
+ if (alreadyUsed) {
28
+ return [alreadyUsed.observable.getValue(), alreadyUsed.observable.setValue.bind(alreadyUsed.observable)]
29
+ }
30
+ const observer = observable.subscribe(callback, getLast)
31
+ this.observers.set(key, observer)
32
+ return [observable.getValue(), observable.setValue.bind(observable)]
33
+ }
34
+
35
+ public readonly stateObservers = new Map<string, ObservableValue<any>>()
36
+
37
+ public useState = <T>(
38
+ key: string,
39
+ initialValue: T,
40
+ callback: ValueChangeCallback<T>,
41
+ ): [value: T, setValue: (newValue: T) => void] => {
42
+ if (!this.stateObservers.has(key)) {
43
+ const newObservable = new ObservableValue<T>(initialValue)
44
+ this.stateObservers.set(key, newObservable)
45
+ newObservable.subscribe(callback)
46
+ }
47
+ const observable = this.stateObservers.get(key) as ObservableValue<T>
48
+ return [observable.getValue(), observable.setValue.bind(observable)]
49
+ }
50
+
51
+ public dispose() {
52
+ this.disposables.forEach((r) => r.dispose())
53
+ this.disposables.clear()
54
+ this.observers.forEach((r) => r.dispose())
55
+ this.observers.clear()
56
+
57
+ this.stateObservers.forEach((r) => r.dispose())
58
+ this.stateObservers.clear()
59
+ }
60
+ }
@@ -9,7 +9,7 @@ import { isShadeComponent } from './models'
9
9
  */
10
10
  export const appendChild = (el: HTMLElement | DocumentFragment, children: ChildrenList) => {
11
11
  for (const child of children) {
12
- if (typeof child === 'string') {
12
+ if (typeof child === 'string' || typeof child === 'number') {
13
13
  el.appendChild(document.createTextNode(child))
14
14
  } else {
15
15
  if (child instanceof HTMLElement || child instanceof DocumentFragment) {
@@ -46,26 +46,37 @@ export const attachDataAttributes = (el: HTMLElement, props: any) => {
46
46
  }
47
47
 
48
48
  /**
49
- * Factory method that creates a component. This should be configured as a default JSX Factory in tsconfig.
50
49
  *
51
- * @param elementType The type of the element (component or stateless component factory method)
52
- * @param props The props for the component
53
- * @param children additional rest parameters will be parsed as children objects
54
- * @returns the created JSX element
50
+ * @param el The Target HTML Element
51
+ * @param props The Props to attach
55
52
  */
56
- export const createComponent = <TProps>(
53
+ export const attachProps = (el: HTMLElement, props: any) => {
54
+ Object.assign(el, props)
55
+
56
+ if (props && (props as any).style) {
57
+ attachStyles(el, props)
58
+ }
59
+ attachDataAttributes(el, props)
60
+ }
61
+
62
+ type CreateComponentArgs<TProps> = [
57
63
  elementType: string | ShadeComponent<TProps>,
58
64
  props: TProps,
59
- ...children: ChildrenList
60
- ) => {
65
+ ...children: ChildrenList,
66
+ ]
67
+
68
+ // eslint-disable-next-line jsdoc/require-param
69
+ /**
70
+ * Factory method that creates a component. This should be configured as a default JSX Factory in tsconfig.
71
+ *
72
+ * @returns the created JSX element
73
+ */
74
+ export const createComponentInner = <TProps>(...[elementType, props, ...children]: CreateComponentArgs<TProps>) => {
61
75
  if (typeof elementType === 'string') {
62
76
  const el = document.createElement(elementType)
63
- Object.assign(el, props)
64
77
 
65
- if (props && (props as any).style) {
66
- attachStyles(el, props)
67
- }
68
- attachDataAttributes(el, props)
78
+ attachProps(el, props)
79
+
69
80
  if (children) {
70
81
  appendChild(el, children)
71
82
  }
@@ -78,8 +89,17 @@ export const createComponent = <TProps>(
78
89
  return undefined
79
90
  }
80
91
 
81
- export const createFragment = (_props: any, children: ChildrenList) => {
92
+ type CreateFragmentArgs = [props: null, ...children: ChildrenList]
93
+
94
+ export const createFragmentInner = (...[_props, ...children]: CreateFragmentArgs) => {
82
95
  const fragment = document.createDocumentFragment()
83
96
  appendChild(fragment, children)
84
97
  return fragment
85
98
  }
99
+
100
+ export const createComponent = <TProps>(...args: CreateComponentArgs<TProps> | CreateFragmentArgs) => {
101
+ if (args[0] === null) {
102
+ return createFragmentInner(...args)
103
+ }
104
+ return createComponentInner(...args)
105
+ }
@@ -24,22 +24,15 @@ describe('Shade Resources integration tests', () => {
24
24
  const obs2 = new ObservableValue('a')
25
25
 
26
26
  const ExampleComponent = Shade({
27
- resources: ({ element }) => [
28
- obs1.subscribe(
29
- (val1) => ((element.querySelector('#val1') as HTMLDivElement).innerHTML = val1.toString()),
30
- true,
31
- ),
32
- obs2.subscribe(
33
- (val2) => ((element.querySelector('#val2') as HTMLDivElement).innerHTML = val2.toString()),
34
- true,
35
- ),
36
- ],
37
- render: () => {
27
+ render: ({ useObservable }) => {
28
+ const [value1] = useObservable('obs1', obs1)
29
+ const [value2] = useObservable('obs2', obs2)
30
+
38
31
  renderCounter()
39
32
  return (
40
33
  <div>
41
- <div id="val1"></div>
42
- <div id="val2"></div>
34
+ <div id="val1">{value1}</div>
35
+ <div id="val2">{value2}</div>
43
36
  </div>
44
37
  )
45
38
  },
@@ -67,6 +60,7 @@ describe('Shade Resources integration tests', () => {
67
60
  expect(document.body.innerHTML).toBe(
68
61
  '<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">a</div></div></shades-example-resource></div>',
69
62
  )
63
+ expect(renderCounter).toBeCalledTimes(2)
70
64
 
71
65
  obs2.setValue('b')
72
66
  expect(document.body.innerHTML).toBe(
@@ -78,6 +72,6 @@ describe('Shade Resources integration tests', () => {
78
72
  expect(obs1.getObservers().length).toBe(0)
79
73
  expect(obs2.getObservers().length).toBe(0)
80
74
 
81
- expect(renderCounter).toBeCalledTimes(1)
75
+ expect(renderCounter).toBeCalledTimes(3)
82
76
  })
83
77
  })