@fun-land/fun-web 1.0.0 → 2.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.
package/src/dom.test.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  attrs,
7
7
  append,
8
8
  bindProperty,
9
+ bindView,
9
10
  addClass,
10
11
  toggleClass,
11
12
  removeClass,
@@ -80,26 +81,24 @@ describe("h()", () => {
80
81
  expect(el.children.length).toBe(2);
81
82
  });
82
83
 
83
- it("should attach event listeners", () => {
84
+ it("should reject event listeners", () => {
84
85
  const handler = jest.fn();
85
- const el = h("button", { onclick: handler });
86
- el.click();
87
- expect(handler).toHaveBeenCalled();
86
+ expect(() => h("button", { onclick: handler })).toThrow(
87
+ "Setting event handlers on dom elements without abort signal leads to memory leaks. Use `hx` or `on` instead."
88
+ );
88
89
  });
89
90
 
90
- it("should handle multiple event types", () => {
91
+ it("should reject multiple event types", () => {
91
92
  const clickHandler = jest.fn();
92
93
  const mouseoverHandler = jest.fn();
93
- const el = h("button", {
94
- onclick: clickHandler,
95
- onmouseover: mouseoverHandler,
96
- });
97
-
98
- el.click();
99
- expect(clickHandler).toHaveBeenCalled();
100
-
101
- el.dispatchEvent(new MouseEvent("mouseover"));
102
- expect(mouseoverHandler).toHaveBeenCalled();
94
+ expect(() =>
95
+ h("button", {
96
+ onclick: clickHandler,
97
+ onmouseover: mouseoverHandler,
98
+ })
99
+ ).toThrow(
100
+ "Setting event handlers on dom elements without abort signal leads to memory leaks. Use `hx` or `on` instead."
101
+ );
103
102
  });
104
103
 
105
104
  it("should skip null and undefined attributes", () => {
@@ -498,6 +497,80 @@ describe("on()", () => {
498
497
  });
499
498
  });
500
499
 
500
+ describe("bindView()", () => {
501
+ it("should default to a div container", () => {
502
+ const controller = new AbortController();
503
+ const state = funState(0);
504
+
505
+ const container = bindView(controller.signal, state, (_signal, value) =>
506
+ h("div", null, String(value))
507
+ ) as HTMLElement;
508
+
509
+ state.set(1);
510
+
511
+ expect(container.tagName).toBe("DIV");
512
+ expect(container.textContent).toBe("1");
513
+
514
+ controller.abort();
515
+ });
516
+
517
+ it("should render into the provided container tag", () => {
518
+ const controller = new AbortController();
519
+ const state = funState(0);
520
+
521
+ const container = bindView(
522
+ controller.signal,
523
+ state,
524
+ (_signal, value) => h("div", null, String(value)),
525
+ { tagName: "section" }
526
+ ) as HTMLElement;
527
+
528
+ state.set(1);
529
+
530
+ expect(container.tagName).toBe("SECTION");
531
+ expect(container.children.length).toBe(1);
532
+ expect(container.textContent).toBe("1");
533
+
534
+ controller.abort();
535
+ });
536
+
537
+ it("should abort previous render on state changes", () => {
538
+ const controller = new AbortController();
539
+ const state = funState(0);
540
+ let abortCount = 0;
541
+ let renderCount = 0;
542
+
543
+ const container = bindView(
544
+ controller.signal,
545
+ state,
546
+ (signal, value) => {
547
+ renderCount += 1;
548
+ signal.addEventListener("abort", () => {
549
+ abortCount += 1;
550
+ });
551
+ return h("div", null, String(value));
552
+ },
553
+ { tagName: "div" }
554
+ );
555
+
556
+ expect(renderCount).toBe(1);
557
+ expect(abortCount).toBe(0);
558
+
559
+ state.set(1);
560
+ expect(renderCount).toBe(2);
561
+ expect(abortCount).toBe(1);
562
+ expect(container.textContent).toBe("1");
563
+
564
+ state.set(2);
565
+ expect(renderCount).toBe(3);
566
+ expect(abortCount).toBe(2);
567
+ expect(container.textContent).toBe("2");
568
+
569
+ controller.abort();
570
+ expect(abortCount).toBe(3);
571
+ });
572
+ });
573
+
501
574
  describe("pipeEndo()", () => {
502
575
  it("should apply functions in order", () => {
503
576
  const el = document.createElement("div");
@@ -585,7 +658,7 @@ describe("renderWhen", () => {
585
658
  state: showState,
586
659
  component: TestComponent,
587
660
  props: { text: "Hello" },
588
- signal: controller.signal
661
+ signal: controller.signal,
589
662
  });
590
663
 
591
664
  expect(container.children.length).toBe(1);
@@ -605,7 +678,7 @@ describe("renderWhen", () => {
605
678
  state: showState,
606
679
  component: TestComponent,
607
680
  props: { text: "Hello" },
608
- signal: controller.signal
681
+ signal: controller.signal,
609
682
  });
610
683
 
611
684
  expect(container.children.length).toBe(0);
@@ -621,7 +694,7 @@ describe("renderWhen", () => {
621
694
  state: showState,
622
695
  component: TestComponent,
623
696
  props: { text: "Hello" },
624
- signal: controller.signal
697
+ signal: controller.signal,
625
698
  });
626
699
 
627
700
  expect(container.children.length).toBe(0);
@@ -642,7 +715,7 @@ describe("renderWhen", () => {
642
715
  state: showState,
643
716
  component: TestComponent,
644
717
  props: { text: "Hello" },
645
- signal: controller.signal
718
+ signal: controller.signal,
646
719
  });
647
720
 
648
721
  const child = container.children[0] as HTMLElement;
@@ -673,7 +746,7 @@ describe("renderWhen", () => {
673
746
  state: showState,
674
747
  component: ComponentWithAbortListener,
675
748
  props: { text: "Hello" },
676
- signal: controller.signal
749
+ signal: controller.signal,
677
750
  });
678
751
 
679
752
  expect(abortCallback).not.toHaveBeenCalled();
@@ -702,7 +775,7 @@ describe("renderWhen", () => {
702
775
  state: showState,
703
776
  component: ComponentWithAbortListener,
704
777
  props: { text: "Hello" },
705
- signal: controller.signal
778
+ signal: controller.signal,
706
779
  });
707
780
 
708
781
  expect(abortCallback).not.toHaveBeenCalled();
@@ -720,7 +793,7 @@ describe("renderWhen", () => {
720
793
  state: showState,
721
794
  component: TestComponent,
722
795
  props: { text: "Hello" },
723
- signal: controller.signal
796
+ signal: controller.signal,
724
797
  });
725
798
 
726
799
  expect(container.children.length).toBe(0);
@@ -748,7 +821,7 @@ describe("renderWhen", () => {
748
821
  state: showState,
749
822
  component: TestComponent,
750
823
  props: { text: "Hello" },
751
- signal: controller.signal
824
+ signal: controller.signal,
752
825
  }) as HTMLElement;
753
826
 
754
827
  expect(container.style.display).toBe("contents");
@@ -764,7 +837,7 @@ describe("renderWhen", () => {
764
837
  state: showState,
765
838
  component: TestComponent,
766
839
  props: { text: "Hello" },
767
- signal: controller.signal
840
+ signal: controller.signal,
768
841
  });
769
842
 
770
843
  expect(container.children.length).toBe(1);
@@ -784,18 +857,14 @@ describe("renderWhen", () => {
784
857
  _signal: AbortSignal,
785
858
  props: { text: string; count: number }
786
859
  ) => {
787
- return h(
788
- "div",
789
- null,
790
- `${props.text}: ${props.count}`
791
- );
860
+ return h("div", null, `${props.text}: ${props.count}`);
792
861
  };
793
862
 
794
863
  const container = renderWhen({
795
864
  state: showState,
796
865
  component: PropsComponent,
797
866
  props: { text: "Count", count: 42 },
798
- signal: controller.signal
867
+ signal: controller.signal,
799
868
  });
800
869
 
801
870
  expect(container.children[0].textContent).toBe("Count: 42");
@@ -805,7 +874,11 @@ describe("renderWhen", () => {
805
874
 
806
875
  test("should work with predicate function", () => {
807
876
  const controller = new AbortController();
808
- enum Status { Loading, Success, Error }
877
+ enum Status {
878
+ Loading,
879
+ Success,
880
+ Error,
881
+ }
809
882
  const statusState = funState(Status.Loading);
810
883
 
811
884
  const container = renderWhen({
@@ -813,7 +886,7 @@ describe("renderWhen", () => {
813
886
  predicate: (status) => status === Status.Success,
814
887
  component: TestComponent,
815
888
  props: { text: "Success!" },
816
- signal: controller.signal
889
+ signal: controller.signal,
817
890
  });
818
891
 
819
892
  // Should not render initially
@@ -834,4 +907,4 @@ describe("renderWhen", () => {
834
907
 
835
908
  controller.abort();
836
909
  });
837
- });
910
+ });
package/src/dom.ts CHANGED
@@ -5,21 +5,11 @@ import { Accessor } from "@fun-land/accessor";
5
5
 
6
6
  export type Enhancer<El extends Element> = (element: El) => El;
7
7
 
8
- /**
9
- * Type-preserving Object.entries helper for objects with known keys
10
- * @internal
11
- */
12
- const entries = <T extends Record<string, unknown>>(
13
- obj: T
14
- ): Array<[keyof T, T[keyof T]]> =>
15
- Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
16
-
17
8
  /**
18
9
  * Create an HTML element with attributes and children
19
10
  *
20
11
  * Convention:
21
12
  * - Properties with dashes (data-*, aria-*) become attributes
22
- * - Properties starting with 'on' become event listeners
23
13
  * - Everything else becomes element properties
24
14
  *
25
15
  * @example
@@ -41,10 +31,9 @@ export const h = <Tag extends keyof HTMLElementTagNameMap>(
41
31
  if (value == null) continue;
42
32
 
43
33
  if (key.startsWith("on") && typeof value === "function") {
44
- // Event listener: onclick, onchange, etc.
45
- const eventName = key.slice(2).toLowerCase();
46
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
47
- element.addEventListener(eventName, value);
34
+ throw new Error(
35
+ "Setting event handlers on dom elements without abort signal leads to memory leaks. Use `hx` or `on` instead."
36
+ );
48
37
  } else if (key.includes("-") || key === "role") {
49
38
  // Attribute: data-*, aria-*, role, etc.
50
39
  element.setAttribute(key, String(value));
@@ -75,7 +64,8 @@ type WritableKeys<T> = {
75
64
 
76
65
  type HxProps<El extends Element> = Partial<{
77
66
  [K in WritableKeys<El> & string]: El[K] | null | undefined;
78
- }>;
67
+ }> &
68
+ Record<string, unknown>;
79
69
 
80
70
  type HxHandlers<El extends Element> = Partial<{
81
71
  [K in keyof GlobalEventHandlersEventMap]: (
@@ -83,9 +73,12 @@ type HxHandlers<El extends Element> = Partial<{
83
73
  ) => void;
84
74
  }>;
85
75
 
86
- type HxBindings<El extends Element> = Partial<{
87
- [K in WritableKeys<El> & string]: FunRead<El[K]>;
88
- }>;
76
+ type BindableRead = {
77
+ get: () => unknown;
78
+ watch: (signal: AbortSignal, callback: (value: unknown) => void) => void;
79
+ };
80
+
81
+ type HxBindings = Record<string, BindableRead>;
89
82
 
90
83
  type HxOptionsBase<El extends Element> = {
91
84
  props?: HxProps<El>;
@@ -95,7 +88,7 @@ type HxOptionsBase<El extends Element> = {
95
88
  type HxOptions<El extends Element> = HxOptionsBase<El> & {
96
89
  signal: AbortSignal;
97
90
  on?: HxHandlers<El>;
98
- bind?: HxBindings<El>;
91
+ bind?: HxBindings;
99
92
  };
100
93
 
101
94
  /**
@@ -129,9 +122,9 @@ export function hx<Tag extends keyof HTMLElementTagNameMap>(
129
122
  const element = document.createElement(tag);
130
123
 
131
124
  if (props) {
132
- for (const [key, value] of entries(props)) {
125
+ for (const [key, value] of Object.entries(props)) {
133
126
  if (value == null) continue;
134
- element[key] = value;
127
+ (element as Record<string, unknown>)[key] = value;
135
128
  }
136
129
  }
137
130
 
@@ -147,21 +140,12 @@ export function hx<Tag extends keyof HTMLElementTagNameMap>(
147
140
  }
148
141
 
149
142
  if (bind) {
150
- const bindElementProperty = <
151
- K extends WritableKeys<HTMLElementTagNameMap[Tag]> & string,
152
- >(
153
- key: K,
154
- state: FunRead<HTMLElementTagNameMap[Tag][K]>
155
- ): void => {
156
- bindProperty<HTMLElementTagNameMap[Tag], K>(key, state, signal)(element);
157
- };
158
-
159
- for (const key of Object.keys(bind) as Array<
160
- WritableKeys<HTMLElementTagNameMap[Tag]> & string
161
- >) {
162
- const state = bind[key];
143
+ for (const [key, state] of Object.entries(bind)) {
163
144
  if (!state) continue;
164
- bindElementProperty(key, state);
145
+ (element as Record<string, unknown>)[key] = state.get();
146
+ state.watch(signal, (value) => {
147
+ (element as Record<string, unknown>)[key] = value;
148
+ });
165
149
  }
166
150
  }
167
151
 
@@ -251,6 +235,38 @@ export const bindProperty =
251
235
  return el;
252
236
  };
253
237
 
238
+ /**
239
+ * Render a single slot from state and abort previous render on updates.
240
+ * Useful when render creates subscriptions, timers, or event handlers.
241
+ * Defaults to a "div" container when no tagName is provided.
242
+ */
243
+ export const bindView = <
244
+ Tag extends keyof HTMLElementTagNameMap = "div",
245
+ T = unknown,
246
+ >(
247
+ signal: AbortSignal,
248
+ state: FunRead<T>,
249
+ render: (regionSignal: AbortSignal, data: T) => Element,
250
+ options?: { tagName?: Tag }
251
+ ): HTMLElementTagNameMap[Tag] => {
252
+ const tagName = (options?.tagName ?? "div") as Tag;
253
+ const element = document.createElement(tagName);
254
+ let childCtrl: AbortController | null = null;
255
+ state.watch(signal, (data) => {
256
+ childCtrl?.abort();
257
+ childCtrl = new AbortController();
258
+ element.replaceChildren(render(childCtrl.signal, data));
259
+ });
260
+ signal.addEventListener(
261
+ "abort",
262
+ () => {
263
+ childCtrl?.abort();
264
+ },
265
+ { once: true }
266
+ );
267
+ return element;
268
+ };
269
+
254
270
  /**
255
271
  * Add CSS classes to an element (returns element for chaining)
256
272
  * @returns {Enhancer}
@@ -530,4 +546,4 @@ export const bindClass =
530
546
  };
531
547
 
532
548
  export const querySelectorAll = <T extends Element>(selector: string): T[] =>
533
- Array.from(document.querySelectorAll(selector));
549
+ Array.from(document.querySelectorAll(selector));
@@ -1,2 +0,0 @@
1
- /** Re-export FunState and funState from fun-state with subscribe support */
2
- export { funState, type FunState } from "@fun-land/fun-state";
@@ -1,3 +0,0 @@
1
- /** Re-export FunState and funState from fun-state with subscribe support */
2
- export { funState } from "@fun-land/fun-state";
3
- //# sourceMappingURL=state.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"state.js","sourceRoot":"","sources":["../../../src/state.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,OAAO,EAAE,QAAQ,EAAiB,MAAM,qBAAqB,CAAC"}
@@ -1,2 +0,0 @@
1
- /** Re-export FunState and funState from fun-state with subscribe support */
2
- export { funState, type FunState } from "@fun-land/fun-state";
package/dist/src/state.js DELETED
@@ -1,7 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.funState = void 0;
4
- /** Re-export FunState and funState from fun-state with subscribe support */
5
- var fun_state_1 = require("@fun-land/fun-state");
6
- Object.defineProperty(exports, "funState", { enumerable: true, get: function () { return fun_state_1.funState; } });
7
- //# sourceMappingURL=state.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"state.js","sourceRoot":"","sources":["../../src/state.ts"],"names":[],"mappings":";;;AAAA,4EAA4E;AAC5E,iDAA8D;AAArD,qGAAA,QAAQ,OAAA"}