@praxisjs/decorators 0.8.0 → 1.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 (76) hide show
  1. package/CHANGELOG.md +139 -3
  2. package/dist/__tests__/lazy.test.js +74 -10
  3. package/dist/__tests__/lazy.test.js.map +1 -1
  4. package/dist/__tests__/properties.test.js +61 -3
  5. package/dist/__tests__/properties.test.js.map +1 -1
  6. package/dist/component/index.d.ts +0 -1
  7. package/dist/component/index.d.ts.map +1 -1
  8. package/dist/component/index.js +0 -1
  9. package/dist/component/index.js.map +1 -1
  10. package/dist/component/lazy.d.ts +15 -1
  11. package/dist/component/lazy.d.ts.map +1 -1
  12. package/dist/component/lazy.js +15 -9
  13. package/dist/component/lazy.js.map +1 -1
  14. package/dist/create-class-decorator.d.ts.map +1 -1
  15. package/dist/create-class-decorator.js +16 -3
  16. package/dist/create-class-decorator.js.map +1 -1
  17. package/dist/create-field-decorator.d.ts +1 -1
  18. package/dist/create-field-decorator.d.ts.map +1 -1
  19. package/dist/create-field-decorator.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/properties/compose.d.ts +8 -1
  25. package/dist/properties/compose.d.ts.map +1 -1
  26. package/dist/properties/compose.js +18 -3
  27. package/dist/properties/compose.js.map +1 -1
  28. package/dist/properties/deep-state.d.ts +2 -1
  29. package/dist/properties/deep-state.d.ts.map +1 -1
  30. package/dist/properties/deep-state.js.map +1 -1
  31. package/dist/properties/history.d.ts +1 -1
  32. package/dist/properties/history.d.ts.map +1 -1
  33. package/dist/properties/index.d.ts +1 -1
  34. package/dist/properties/index.d.ts.map +1 -1
  35. package/dist/properties/index.js +1 -1
  36. package/dist/properties/index.js.map +1 -1
  37. package/dist/properties/persisted.d.ts +1 -1
  38. package/dist/properties/persisted.d.ts.map +1 -1
  39. package/dist/properties/prop.d.ts +1 -1
  40. package/dist/properties/prop.d.ts.map +1 -1
  41. package/dist/properties/resource.d.ts +1 -1
  42. package/dist/properties/resource.d.ts.map +1 -1
  43. package/dist/properties/slot.d.ts +1 -1
  44. package/dist/properties/slot.d.ts.map +1 -1
  45. package/dist/properties/state.d.ts +2 -1
  46. package/dist/properties/state.d.ts.map +1 -1
  47. package/dist/properties/state.js.map +1 -1
  48. package/dist/properties/synced.d.ts +1 -1
  49. package/dist/properties/synced.d.ts.map +1 -1
  50. package/dist/reactive-host.d.ts +4 -0
  51. package/dist/reactive-host.d.ts.map +1 -0
  52. package/dist/reactive-host.js +2 -0
  53. package/dist/reactive-host.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/__tests__/lazy.test.ts +88 -10
  56. package/src/__tests__/properties.test.ts +71 -3
  57. package/src/component/index.ts +0 -1
  58. package/src/component/lazy.ts +29 -7
  59. package/src/create-class-decorator.ts +16 -4
  60. package/src/create-field-decorator.ts +3 -3
  61. package/src/index.ts +3 -1
  62. package/src/properties/compose.ts +21 -8
  63. package/src/properties/deep-state.ts +3 -1
  64. package/src/properties/index.ts +1 -1
  65. package/src/properties/state.ts +3 -1
  66. package/src/reactive-host.ts +3 -0
  67. package/dist/__tests__/virtual.test.d.ts +0 -2
  68. package/dist/__tests__/virtual.test.d.ts.map +0 -1
  69. package/dist/__tests__/virtual.test.js +0 -352
  70. package/dist/__tests__/virtual.test.js.map +0 -1
  71. package/dist/component/virtual.d.ts +0 -3
  72. package/dist/component/virtual.d.ts.map +0 -1
  73. package/dist/component/virtual.js +0 -102
  74. package/dist/component/virtual.js.map +0 -1
  75. package/src/__tests__/virtual.test.ts +0 -371
  76. package/src/component/virtual.tsx +0 -163
@@ -967,7 +967,7 @@ describe("@Compose decorator — new cases", () => {
967
967
  }).toThrow("setup failed");
968
968
  });
969
969
 
970
- it("string argument resolution where the instance property is undefined", () => {
970
+ it("string argument that matches no instance property falls back to the literal string", () => {
971
971
  class ArgComposable {
972
972
  constructor(private val: unknown) {}
973
973
  setup() { return { resolved: this.val }; }
@@ -978,11 +978,79 @@ describe("@Compose decorator — new cases", () => {
978
978
  Compose(ArgComposable as any, "missingProp")(undefined, ctx);
979
979
 
980
980
  const instance = new TestComponent();
981
- // missingProp is not set on instance — resolves to undefined
981
+ // missingProp is not set on instance — falls back to the literal "missingProp"
982
982
  run(instance);
983
983
 
984
984
  const view = (instance as unknown as Record<string, unknown>).strArgUndef as Record<string, unknown>;
985
- expect(view.resolved).toBeUndefined();
985
+ expect(view.resolved).toBe("missingProp");
986
+ });
987
+
988
+ it("string argument that matches an instance property resolves to its value", () => {
989
+ class ArgComposable {
990
+ constructor(private val: unknown) {}
991
+ setup() { return { resolved: this.val }; }
992
+ }
993
+
994
+ const { ctx, run } = fieldCtx("strArgFound");
995
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
996
+ Compose(ArgComposable as any, "myProp")(undefined, ctx);
997
+
998
+ const instance = new TestComponent();
999
+ (instance as unknown as Record<string, unknown>).myProp = 42;
1000
+ run(instance);
1001
+
1002
+ const view = (instance as unknown as Record<string, unknown>).strArgFound as Record<string, unknown>;
1003
+ expect(view.resolved).toBe(42);
1004
+ });
1005
+ });
1006
+
1007
+ // ── getter() helper ───────────────────────────────────────────────────────────
1008
+
1009
+ import { getter } from "../properties/compose";
1010
+
1011
+ describe("getter() factory", () => {
1012
+ it("resolves to a function that reads the named property from the instance", () => {
1013
+ class ArgComposable {
1014
+ constructor(private getVal: () => unknown) {}
1015
+ setup() { return { current: this.getVal() }; }
1016
+ }
1017
+
1018
+ const { ctx, run } = fieldCtx("withGetter");
1019
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1020
+ Compose(ArgComposable as any, getter("dynProp"))(undefined, ctx);
1021
+
1022
+ const instance = new TestComponent();
1023
+ (instance as unknown as Record<string, unknown>).dynProp = 99;
1024
+ run(instance);
1025
+
1026
+ const view = (instance as unknown as Record<string, unknown>).withGetter as Record<string, unknown>;
1027
+ expect(view.current).toBe(99);
1028
+ });
1029
+
1030
+ it("getter() produces a live reader — reads the property at call time", () => {
1031
+ class ArgComposable {
1032
+ constructor(private getVal: () => unknown) {}
1033
+ setup() { return { read: this.getVal }; }
1034
+ }
1035
+
1036
+ const { ctx, run } = fieldCtx("liveGetter");
1037
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1038
+ Compose(ArgComposable as any, getter("mutableProp"))(undefined, ctx);
1039
+
1040
+ const instance = new TestComponent();
1041
+ (instance as unknown as Record<string, unknown>).mutableProp = "first";
1042
+ run(instance);
1043
+
1044
+ const view = (instance as unknown as Record<string, unknown>).liveGetter as Record<string, () => unknown>;
1045
+ expect(view.read()).toBe("first");
1046
+
1047
+ (instance as unknown as Record<string, unknown>).mutableProp = "second";
1048
+ expect(view.read()).toBe("second");
1049
+ });
1050
+
1051
+ it("getter() is marked as a ComposeFactory (has __isComposeFactory)", () => {
1052
+ const g = getter("foo");
1053
+ expect((g as unknown as Record<string, unknown>).__isComposeFactory).toBe(true);
986
1054
  });
987
1055
  });
988
1056
 
@@ -1,3 +1,2 @@
1
1
  export { Component } from "./component";
2
2
  export { Lazy } from "./lazy";
3
- export { Virtual } from "./virtual";
@@ -2,13 +2,26 @@ import { type RootComponent, signal } from "@praxisjs/core/internal";
2
2
 
3
3
  import { ClassBehavior, createClassDecorator, type ClassEnhancement } from "../create-class-decorator";
4
4
 
5
+ export interface LazyOptions {
6
+ /** Placeholder height in pixels while the component is off-screen. */
7
+ placeholder?: number;
8
+ /**
9
+ * Scroll container to observe against. Defaults to the viewport (`null`).
10
+ * Pass a ref object `{ current: HTMLElement | null }` to scope intersection
11
+ * to a specific scrollable container.
12
+ */
13
+ root?: { current: HTMLElement | null } | null;
14
+ /** Extra margin around the root before triggering (e.g. `"200px"`). Defaults to `"100px"`. */
15
+ rootMargin?: string;
16
+ }
17
+
5
18
  class LazyBehavior extends ClassBehavior {
6
- constructor(private readonly placeholder: number) {
19
+ constructor(private readonly options: LazyOptions) {
7
20
  super();
8
21
  }
9
22
 
10
23
  create(instance: RootComponent): ClassEnhancement {
11
- const placeholder = this.placeholder;
24
+ const { placeholder = 200, root, rootMargin = "100px" } = this.options;
12
25
  const visible = signal(false);
13
26
  let observer: IntersectionObserver | undefined;
14
27
 
@@ -25,6 +38,9 @@ class LazyBehavior extends ClassBehavior {
25
38
 
26
39
  if (!visible()) el.style.minHeight = `${String(placeholder)}px`;
27
40
 
41
+ const rootEl =
42
+ root === undefined || root === null ? null : root.current ?? null;
43
+
28
44
  observer = new IntersectionObserver(
29
45
  (entries) => {
30
46
  if (entries[0]?.isIntersecting) {
@@ -33,7 +49,7 @@ class LazyBehavior extends ClassBehavior {
33
49
  observer?.disconnect();
34
50
  }
35
51
  },
36
- { rootMargin: "100px" },
52
+ { root: rootEl, rootMargin },
37
53
  );
38
54
 
39
55
  observer.observe(el);
@@ -44,13 +60,19 @@ class LazyBehavior extends ClassBehavior {
44
60
  },
45
61
 
46
62
  render(originalRender) {
47
- if (!visible()) return null;
48
- return originalRender();
63
+ return (() => {
64
+ if (!visible()) return null;
65
+ return originalRender();
66
+ }) as unknown as Node;
49
67
  },
50
68
  };
51
69
  }
52
70
  }
53
71
 
54
- export function Lazy(placeholder = 200) {
55
- return createClassDecorator(new LazyBehavior(placeholder));
72
+ export function Lazy(placeholderOrOptions: number | LazyOptions = 200) {
73
+ const options: LazyOptions =
74
+ typeof placeholderOrOptions === "number"
75
+ ? { placeholder: placeholderOrOptions }
76
+ : placeholderOrOptions;
77
+ return createClassDecorator(new LazyBehavior(options));
56
78
  }
@@ -19,6 +19,12 @@ export abstract class ClassBehavior {
19
19
  initialize?(_Enhanced: new (...args: any[]) => unknown, _original: new (...args: any[]) => unknown): void;
20
20
  }
21
21
 
22
+ // Tracks instances whose render is currently being called as `originalRender`
23
+ // from within an enhancement. When set, the Enhanced render skips the
24
+ // this._enh.render dispatch so stacked decorators (e.g. @Lazy + @Component)
25
+ // don't re-enter the outermost enhancement recursively.
26
+ const _originalRenderInProgress = new WeakSet();
27
+
22
28
  export function createClassDecorator(behavior: ClassBehavior) {
23
29
  return function <
24
30
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -40,12 +46,18 @@ export function createClassDecorator(behavior: ClassBehavior) {
40
46
  }
41
47
 
42
48
  render(...args: unknown[]): Node | Node[] | null {
43
- const originalRender = () =>
44
- (constructor.prototype as { render: (...a: unknown[]) => Node | Node[] | null }).render.apply(this, args);
45
- if (this._enh.render) {
49
+ const originalRender = () => {
50
+ _originalRenderInProgress.add(this);
51
+ try {
52
+ return (constructor.prototype as { render: (...a: unknown[]) => Node | Node[] | null }).render.apply(this, args);
53
+ } finally {
54
+ _originalRenderInProgress.delete(this);
55
+ }
56
+ };
57
+ if (this._enh.render && !_originalRenderInProgress.has(this)) {
46
58
  return this._enh.render(originalRender);
47
59
  }
48
- return originalRender();
60
+ return (constructor.prototype as { render: (...a: unknown[]) => Node | Node[] | null }).render.apply(this, args);
49
61
  }
50
62
  };
51
63
 
@@ -18,12 +18,12 @@ export interface FieldBehavior<T = object> {
18
18
  }
19
19
 
20
20
  export function createFieldDecorator<T = StatefulComponent>(behavior: FieldBehavior<T>) {
21
- return function (
21
+ return function <This extends T, Value = unknown>(
22
22
  _value: undefined,
23
- context: ClassFieldDecoratorContext<T>,
23
+ context: ClassFieldDecoratorContext<This, Value>,
24
24
  ): void {
25
25
  context.addInitializer(function (this: unknown) {
26
- const instance = this as T & StatefulComponent & Record<string, unknown>;
26
+ const instance = this as This & StatefulComponent & Record<string, unknown>;
27
27
  const name = context.name as string;
28
28
  const initialValue = instance[name];
29
29
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { Component, Lazy, Virtual } from "./component";
1
+ export { Component, Lazy } from "./component";
2
2
  export {
3
3
  Memo,
4
4
  Bind,
@@ -23,6 +23,7 @@ export {
23
23
  History,
24
24
  type HistoryOf,
25
25
  Compose,
26
+ getter,
26
27
  Resource,
27
28
  type ResourceInstance,
28
29
  type ResourceOptions,
@@ -35,6 +36,7 @@ export {
35
36
  type FieldBehavior,
36
37
  type FieldBinding,
37
38
  } from "./create-field-decorator";
39
+ export { type ReactiveHost } from "./reactive-host";
38
40
  export {
39
41
  createClassDecorator,
40
42
  ClassBehavior,
@@ -2,9 +2,21 @@ import type { StatefulComponent, Composable } from "@praxisjs/core";
2
2
 
3
3
  import { createFieldDecorator } from "../create-field-decorator";
4
4
 
5
- function isReactive(
6
- value: unknown,
7
- ): value is () => unknown {
5
+ interface ComposeFactory { __isComposeFactory: true; resolve: (instance: object) => unknown }
6
+
7
+ /** Wraps a property as a live getter — passes `() => instance[propName]` to the composable. */
8
+ export function getter(propName: string): ComposeFactory {
9
+ return {
10
+ __isComposeFactory: true,
11
+ resolve: (instance) => () => (instance as Record<string, unknown>)[propName],
12
+ };
13
+ }
14
+
15
+ function isFactory(value: unknown): value is ComposeFactory {
16
+ return typeof value === "object" && value !== null && "__isComposeFactory" in value;
17
+ }
18
+
19
+ function isReactive(value: unknown): value is () => unknown {
8
20
  return (
9
21
  typeof value === "function" &&
10
22
  ("__isSignal" in (value as object) || "__isComputed" in (value as object))
@@ -34,11 +46,12 @@ export function Compose(
34
46
  ) {
35
47
  return createFieldDecorator({
36
48
  bind(instance: StatefulComponent, _name: string, _initialValue: unknown) {
37
- const resolvedArgs = ctorArgs.map((arg) =>
38
- typeof arg === "string"
39
- ? (instance as unknown as Record<string, unknown>)[arg]
40
- : arg,
41
- );
49
+ const resolvedArgs = ctorArgs.map((arg) => {
50
+ if (isFactory(arg)) return arg.resolve(instance);
51
+ if (typeof arg !== "string") return arg;
52
+ const prop = (instance as unknown as Record<string, unknown>)[arg];
53
+ return prop !== undefined ? prop : arg;
54
+ });
42
55
 
43
56
  const composable = new ComposableClass(...resolvedArgs);
44
57
  const view = buildView(composable.setup());
@@ -2,6 +2,8 @@ import { signal } from "@praxisjs/core/internal";
2
2
 
3
3
  import { createFieldDecorator } from "../create-field-decorator";
4
4
 
5
+ import type { ReactiveHost } from "../reactive-host";
6
+
5
7
  function deepProxy<T extends object>(target: T, notify: () => void): T {
6
8
  return new Proxy(target, {
7
9
  get(obj, key, receiver) {
@@ -25,7 +27,7 @@ function deepProxy<T extends object>(target: T, notify: () => void): T {
25
27
  }
26
28
 
27
29
  export function DeepState() {
28
- return createFieldDecorator({
30
+ return createFieldDecorator<ReactiveHost>({
29
31
  bind(instance, _name, initialValue) {
30
32
  const version = signal(0);
31
33
  let current = initialValue;
@@ -4,7 +4,7 @@ export { Computed } from "./computed";
4
4
  export { Persisted } from "./persisted";
5
5
  export { Slot, initSlots } from "./slot";
6
6
  export { History, type HistoryOf } from "./history";
7
- export { Compose } from "./compose";
7
+ export { Compose, getter } from "./compose";
8
8
  export { Resource, type ResourceInstance, type ResourceOptions } from "./resource";
9
9
  export { Synced } from "./synced";
10
10
  export { DeepState } from "./deep-state";
@@ -2,8 +2,10 @@ import { signal } from "@praxisjs/core/internal";
2
2
 
3
3
  import { createFieldDecorator } from "../create-field-decorator";
4
4
 
5
+ import type { ReactiveHost } from "../reactive-host";
6
+
5
7
  export function State() {
6
- return createFieldDecorator({
8
+ return createFieldDecorator<ReactiveHost>({
7
9
  bind(instance, _name, initialValue) {
8
10
  const sig = signal(initialValue);
9
11
  return {
@@ -0,0 +1,3 @@
1
+ export interface ReactiveHost {
2
+ _stateDirty: boolean;
3
+ }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=virtual.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"virtual.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/virtual.test.ts"],"names":[],"mappings":""}