@praxisjs/decorators 0.4.3 → 0.6.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/CHANGELOG.md +39 -0
- package/dist/__tests__/component.test.js +10 -1
- package/dist/__tests__/component.test.js.map +1 -1
- package/dist/__tests__/compose.test.d.ts +2 -0
- package/dist/__tests__/compose.test.d.ts.map +1 -0
- package/dist/__tests__/compose.test.js +171 -0
- package/dist/__tests__/compose.test.js.map +1 -0
- package/dist/__tests__/events.test.js +26 -0
- package/dist/__tests__/events.test.js.map +1 -1
- package/dist/__tests__/functions.test.js +205 -47
- package/dist/__tests__/functions.test.js.map +1 -1
- package/dist/__tests__/lazy.test.js +5 -7
- package/dist/__tests__/lazy.test.js.map +1 -1
- package/dist/__tests__/memo.test.js +114 -25
- package/dist/__tests__/memo.test.js.map +1 -1
- package/dist/__tests__/properties.test.js +193 -0
- package/dist/__tests__/properties.test.js.map +1 -1
- package/dist/__tests__/resource.test.d.ts +2 -0
- package/dist/__tests__/resource.test.d.ts.map +1 -0
- package/dist/__tests__/resource.test.js +127 -0
- package/dist/__tests__/resource.test.js.map +1 -0
- package/dist/__tests__/slot.test.js +32 -0
- package/dist/__tests__/slot.test.js.map +1 -1
- package/dist/__tests__/until.test.d.ts +2 -0
- package/dist/__tests__/until.test.d.ts.map +1 -0
- package/dist/__tests__/until.test.js +88 -0
- package/dist/__tests__/until.test.js.map +1 -0
- package/dist/__tests__/virtual.test.js +164 -15
- package/dist/__tests__/virtual.test.js.map +1 -1
- package/dist/component/component.d.ts +1 -1
- package/dist/component/component.d.ts.map +1 -1
- package/dist/component/component.js +15 -10
- package/dist/component/component.js.map +1 -1
- package/dist/component/lazy.d.ts.map +1 -1
- package/dist/component/lazy.js +29 -30
- package/dist/component/lazy.js.map +1 -1
- package/dist/component/virtual.d.ts.map +1 -1
- package/dist/component/virtual.js +49 -50
- package/dist/component/virtual.js.map +1 -1
- package/dist/create-class-decorator.d.ts +16 -0
- package/dist/create-class-decorator.d.ts.map +1 -0
- package/dist/create-class-decorator.js +35 -0
- package/dist/create-class-decorator.js.map +1 -0
- package/dist/create-field-decorator.d.ts +14 -0
- package/dist/create-field-decorator.d.ts.map +1 -0
- package/dist/create-field-decorator.js +37 -0
- package/dist/create-field-decorator.js.map +1 -0
- package/dist/create-getter-decorator.d.ts +18 -0
- package/dist/create-getter-decorator.d.ts.map +1 -0
- package/dist/create-getter-decorator.js +15 -0
- package/dist/create-getter-decorator.js.map +1 -0
- package/dist/create-lifecycle-method-decorator.d.ts +6 -0
- package/dist/create-lifecycle-method-decorator.d.ts.map +1 -0
- package/dist/create-lifecycle-method-decorator.js +22 -0
- package/dist/create-lifecycle-method-decorator.js.map +1 -0
- package/dist/create-method-decorator.d.ts +6 -0
- package/dist/create-method-decorator.d.ts.map +1 -0
- package/dist/create-method-decorator.js +14 -0
- package/dist/create-method-decorator.js.map +1 -0
- package/dist/events/emit.d.ts +1 -1
- package/dist/events/emit.d.ts.map +1 -1
- package/dist/events/emit.js +24 -29
- package/dist/events/emit.js.map +1 -1
- package/dist/events/on-command.d.ts +1 -2
- package/dist/events/on-command.d.ts.map +1 -1
- package/dist/events/on-command.js +15 -32
- package/dist/events/on-command.js.map +1 -1
- package/dist/functions/bind.d.ts +1 -2
- package/dist/functions/bind.d.ts.map +1 -1
- package/dist/functions/bind.js +4 -11
- package/dist/functions/bind.js.map +1 -1
- package/dist/functions/debounce.d.ts.map +1 -1
- package/dist/functions/debounce.js +21 -20
- package/dist/functions/debounce.js.map +1 -1
- package/dist/functions/index.d.ts +1 -0
- package/dist/functions/index.d.ts.map +1 -1
- package/dist/functions/index.js +1 -0
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/log.d.ts +1 -2
- package/dist/functions/log.d.ts.map +1 -1
- package/dist/functions/log.js +40 -38
- package/dist/functions/log.js.map +1 -1
- package/dist/functions/memo.d.ts +1 -2
- package/dist/functions/memo.d.ts.map +1 -1
- package/dist/functions/memo.js +29 -27
- package/dist/functions/memo.js.map +1 -1
- package/dist/functions/once.d.ts +1 -2
- package/dist/functions/once.d.ts.map +1 -1
- package/dist/functions/once.js +14 -12
- package/dist/functions/once.js.map +1 -1
- package/dist/functions/retry.d.ts +1 -2
- package/dist/functions/retry.d.ts.map +1 -1
- package/dist/functions/retry.js +23 -20
- package/dist/functions/retry.js.map +1 -1
- package/dist/functions/throttle.d.ts +1 -2
- package/dist/functions/throttle.d.ts.map +1 -1
- package/dist/functions/throttle.js +11 -17
- package/dist/functions/throttle.js.map +1 -1
- package/dist/functions/until.d.ts +2 -0
- package/dist/functions/until.d.ts.map +1 -0
- package/dist/functions/until.js +17 -0
- package/dist/functions/until.js.map +1 -0
- package/dist/functions/watch.d.ts +1 -1
- package/dist/functions/watch.d.ts.map +1 -1
- package/dist/functions/watch.js +25 -39
- package/dist/functions/watch.js.map +1 -1
- package/dist/functions/when.d.ts +1 -2
- package/dist/functions/when.d.ts.map +1 -1
- package/dist/functions/when.js +10 -26
- package/dist/functions/when.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/properties/compose.d.ts +3 -0
- package/dist/properties/compose.d.ts.map +1 -0
- package/dist/properties/compose.js +40 -0
- package/dist/properties/compose.js.map +1 -0
- package/dist/properties/computed.d.ts +1 -2
- package/dist/properties/computed.d.ts.map +1 -1
- package/dist/properties/computed.js +10 -9
- package/dist/properties/computed.js.map +1 -1
- package/dist/properties/history.d.ts.map +1 -1
- package/dist/properties/history.js +37 -32
- package/dist/properties/history.js.map +1 -1
- package/dist/properties/index.d.ts +2 -0
- package/dist/properties/index.d.ts.map +1 -1
- package/dist/properties/index.js +2 -0
- package/dist/properties/index.js.map +1 -1
- package/dist/properties/persisted.d.ts +1 -2
- package/dist/properties/persisted.d.ts.map +1 -1
- package/dist/properties/persisted.js +13 -29
- package/dist/properties/persisted.js.map +1 -1
- package/dist/properties/prop.d.ts +1 -2
- package/dist/properties/prop.d.ts.map +1 -1
- package/dist/properties/prop.js +21 -25
- package/dist/properties/prop.js.map +1 -1
- package/dist/properties/resource.d.ts +14 -0
- package/dist/properties/resource.d.ts.map +1 -0
- package/dist/properties/resource.js +27 -0
- package/dist/properties/resource.js.map +1 -0
- package/dist/properties/slot.d.ts +1 -2
- package/dist/properties/slot.d.ts.map +1 -1
- package/dist/properties/slot.js +15 -20
- package/dist/properties/slot.js.map +1 -1
- package/dist/properties/state.d.ts +1 -2
- package/dist/properties/state.d.ts.map +1 -1
- package/dist/properties/state.js +13 -18
- package/dist/properties/state.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/component.test.ts +10 -0
- package/src/__tests__/compose.test.ts +202 -0
- package/src/__tests__/events.test.ts +32 -0
- package/src/__tests__/functions.test.ts +257 -46
- package/src/__tests__/lazy.test.ts +3 -5
- package/src/__tests__/memo.test.ts +143 -25
- package/src/__tests__/properties.test.ts +233 -0
- package/src/__tests__/resource.test.ts +141 -0
- package/src/__tests__/slot.test.ts +38 -0
- package/src/__tests__/until.test.ts +113 -0
- package/src/__tests__/virtual.test.ts +135 -7
- package/src/component/component.ts +27 -12
- package/src/component/lazy.ts +33 -37
- package/src/component/virtual.tsx +67 -75
- package/src/create-class-decorator.ts +61 -0
- package/src/create-field-decorator.ts +66 -0
- package/src/create-getter-decorator.ts +52 -0
- package/src/create-lifecycle-method-decorator.ts +38 -0
- package/src/create-method-decorator.ts +26 -0
- package/src/events/emit.ts +22 -33
- package/src/events/on-command.ts +22 -47
- package/src/functions/bind.ts +4 -15
- package/src/functions/debounce.ts +21 -23
- package/src/functions/index.ts +1 -0
- package/src/functions/log.ts +40 -43
- package/src/functions/memo.ts +28 -42
- package/src/functions/once.ts +13 -16
- package/src/functions/retry.ts +21 -22
- package/src/functions/throttle.ts +11 -24
- package/src/functions/until.ts +19 -0
- package/src/functions/watch.ts +27 -43
- package/src/functions/when.ts +11 -36
- package/src/index.ts +29 -0
- package/src/properties/compose.ts +53 -0
- package/src/properties/computed.ts +11 -13
- package/src/properties/history.ts +42 -41
- package/src/properties/index.ts +2 -0
- package/src/properties/persisted.ts +17 -58
- package/src/properties/prop.ts +21 -31
- package/src/properties/resource.ts +34 -0
- package/src/properties/slot.ts +17 -30
- package/src/properties/state.ts +14 -24
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { signal, computed } from "@praxisjs/core/internal";
|
|
4
|
+
|
|
5
|
+
import { Until } from "../functions/until";
|
|
6
|
+
|
|
7
|
+
// Simulates TC39 decorator context for a method decorator
|
|
8
|
+
function mockMethodContext(name: string) {
|
|
9
|
+
const initializers: Array<(this: object) => void> = [];
|
|
10
|
+
const ctx = {
|
|
11
|
+
name,
|
|
12
|
+
kind: "method" as const,
|
|
13
|
+
addInitializer(fn: (this: object) => void) {
|
|
14
|
+
initializers.push(fn);
|
|
15
|
+
},
|
|
16
|
+
runInitializers(instance: object) {
|
|
17
|
+
initializers.forEach((fn) => { fn.call(instance); });
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
return ctx;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── @Until ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe("Until", () => {
|
|
26
|
+
it("resolves immediately when the signal is already truthy", async () => {
|
|
27
|
+
const s = signal(42);
|
|
28
|
+
const obj: Record<string, unknown> = { count: s };
|
|
29
|
+
|
|
30
|
+
const ctx = mockMethodContext("wait");
|
|
31
|
+
Until("count")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
|
|
32
|
+
ctx.runInitializers(obj);
|
|
33
|
+
|
|
34
|
+
const result = await (obj.wait as () => Promise<number>)();
|
|
35
|
+
expect(result).toBe(42);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("resolves when the signal becomes truthy", async () => {
|
|
39
|
+
const s = signal<number>(0);
|
|
40
|
+
const obj: Record<string, unknown> = { count: s };
|
|
41
|
+
|
|
42
|
+
const ctx = mockMethodContext("wait");
|
|
43
|
+
Until("count")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
|
|
44
|
+
ctx.runInitializers(obj);
|
|
45
|
+
|
|
46
|
+
const promise = (obj.wait as () => Promise<number>)();
|
|
47
|
+
s.set(5);
|
|
48
|
+
expect(await promise).toBe(5);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("resolves with a computed signal", async () => {
|
|
52
|
+
const s = signal(0);
|
|
53
|
+
const doubled = computed(() => s() * 2);
|
|
54
|
+
const obj: Record<string, unknown> = { doubled };
|
|
55
|
+
|
|
56
|
+
const ctx = mockMethodContext("waitDoubled");
|
|
57
|
+
Until("doubled")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
|
|
58
|
+
ctx.runInitializers(obj);
|
|
59
|
+
|
|
60
|
+
const promise = (obj.waitDoubled as () => Promise<number>)();
|
|
61
|
+
s.set(3); // doubled becomes 6 (truthy)
|
|
62
|
+
expect(await promise).toBe(6);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("each call returns a new promise", async () => {
|
|
66
|
+
const s = signal<string>("");
|
|
67
|
+
const obj: Record<string, unknown> = { name: s };
|
|
68
|
+
|
|
69
|
+
const ctx = mockMethodContext("waitName");
|
|
70
|
+
Until("name")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
|
|
71
|
+
ctx.runInitializers(obj);
|
|
72
|
+
|
|
73
|
+
const wait = obj.waitName as () => Promise<string>;
|
|
74
|
+
|
|
75
|
+
const p1 = wait();
|
|
76
|
+
const p2 = wait();
|
|
77
|
+
expect(p1).not.toBe(p2);
|
|
78
|
+
|
|
79
|
+
s.set("alice");
|
|
80
|
+
expect(await p1).toBe("alice");
|
|
81
|
+
expect(await p2).toBe("alice");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not resolve when signal stays falsy", async () => {
|
|
85
|
+
const s = signal<number>(0);
|
|
86
|
+
const obj: Record<string, unknown> = { count: s };
|
|
87
|
+
|
|
88
|
+
const ctx = mockMethodContext("wait");
|
|
89
|
+
Until("count")(() => {}, ctx as unknown as ClassMethodDecoratorContext);
|
|
90
|
+
ctx.runInitializers(obj);
|
|
91
|
+
|
|
92
|
+
let resolved = false;
|
|
93
|
+
(obj.wait as () => Promise<number>)().then(() => { resolved = true; });
|
|
94
|
+
|
|
95
|
+
await new Promise((res) => { setTimeout(res, 10); });
|
|
96
|
+
expect(resolved).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("ignores the original method body", async () => {
|
|
100
|
+
const s = signal("hello");
|
|
101
|
+
const obj: Record<string, unknown> = { value: s };
|
|
102
|
+
|
|
103
|
+
let bodyCalled = false;
|
|
104
|
+
const original = () => { bodyCalled = true; };
|
|
105
|
+
|
|
106
|
+
const ctx = mockMethodContext("wait");
|
|
107
|
+
Until("value")(original, ctx as unknown as ClassMethodDecoratorContext);
|
|
108
|
+
ctx.runInitializers(obj);
|
|
109
|
+
|
|
110
|
+
await (obj.wait as () => Promise<string>)();
|
|
111
|
+
expect(bodyCalled).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -158,16 +158,16 @@ describe("Virtual decorator", () => {
|
|
|
158
158
|
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
159
159
|
|
|
160
160
|
instance.onMount?.();
|
|
161
|
-
// First render -
|
|
162
|
-
instance.render();
|
|
161
|
+
// First render - sets up effects with the spacerTop/itemsSlot/spacerBottom elements
|
|
162
|
+
const outer = instance.render() as HTMLElement;
|
|
163
163
|
|
|
164
164
|
// Change scrollTop to trigger effect re-run (covers the while loop removing old nodes)
|
|
165
165
|
Object.defineProperty(container, "scrollTop", { configurable: true, value: 500 });
|
|
166
166
|
container.dispatchEvent(new Event("scroll"));
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
const
|
|
170
|
-
expect(
|
|
168
|
+
// buffer=0, itemHeight=50, scrollTop=500 → startIdx=10 → offsetTop=500px
|
|
169
|
+
const spacerTop = outer.children[0] as HTMLElement;
|
|
170
|
+
expect(spacerTop.getAttribute("style")).toContain("500px");
|
|
171
171
|
|
|
172
172
|
document.body.removeChild(container);
|
|
173
173
|
});
|
|
@@ -211,6 +211,22 @@ describe("Virtual decorator", () => {
|
|
|
211
211
|
expect(wrapper.children.length).toBe(0);
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
it("uses empty array when items is undefined (host.items ?? [])", () => {
|
|
215
|
+
class NoItems extends StatefulComponent {
|
|
216
|
+
// items is intentionally not defined
|
|
217
|
+
renderItem() { return document.createElement("div"); }
|
|
218
|
+
render() { return null; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const Wrapped = applyVirtual(NoItems as AnyConstructor, 50, 0);
|
|
222
|
+
const instance = new Wrapped();
|
|
223
|
+
// render() should work fine with 0 items
|
|
224
|
+
const result = instance.render() as HTMLElement;
|
|
225
|
+
expect(result).toBeInstanceOf(HTMLElement);
|
|
226
|
+
const style = result.getAttribute("style") ?? "";
|
|
227
|
+
expect(style).toContain("0px"); // totalH = 0 * 50 = 0
|
|
228
|
+
});
|
|
229
|
+
|
|
214
230
|
it("onUnmount without prior onMount does not throw", () => {
|
|
215
231
|
const Wrapped = applyVirtual(ListComp as AnyConstructor, 50);
|
|
216
232
|
const instance = new Wrapped();
|
|
@@ -230,12 +246,124 @@ describe("Virtual decorator", () => {
|
|
|
230
246
|
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
231
247
|
|
|
232
248
|
instance.onMount?.();
|
|
249
|
+
// Render to set up effects that observe scrollTop
|
|
250
|
+
const outer = instance.render() as HTMLElement;
|
|
233
251
|
|
|
234
252
|
Object.defineProperty(container, "scrollTop", { configurable: true, value: 200 });
|
|
235
253
|
container.dispatchEvent(new Event("scroll"));
|
|
236
254
|
|
|
237
|
-
|
|
238
|
-
|
|
255
|
+
// buffer=3, itemHeight=50, scrollTop=200 → startIdx=1 → offsetTop=50px
|
|
256
|
+
const spacerTop = outer.children[0] as HTMLElement;
|
|
257
|
+
expect(spacerTop.getAttribute("style")).toContain("50px");
|
|
258
|
+
document.body.removeChild(container);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("itemHeight = 0 throws a descriptive error on onMount", () => {
|
|
262
|
+
class ZeroHeightList extends StatefulComponent {
|
|
263
|
+
items = ["a", "b"];
|
|
264
|
+
renderItem(item: unknown) {
|
|
265
|
+
const el = document.createElement("div");
|
|
266
|
+
el.textContent = String(item);
|
|
267
|
+
return el;
|
|
268
|
+
}
|
|
269
|
+
render() { return null; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const Wrapped = applyVirtual(ZeroHeightList as AnyConstructor, 0);
|
|
273
|
+
const instance = new Wrapped();
|
|
274
|
+
|
|
275
|
+
const container = document.createElement("div");
|
|
276
|
+
document.body.appendChild(container);
|
|
277
|
+
const anchor = document.createComment("end");
|
|
278
|
+
container.appendChild(anchor);
|
|
279
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
280
|
+
|
|
281
|
+
expect(() => instance.onMount?.()).toThrow(/itemHeight must be a positive number/);
|
|
282
|
+
document.body.removeChild(container);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("itemHeight = 0 in render() does not divide by zero (no container)", () => {
|
|
286
|
+
class ZeroHeightList2 extends StatefulComponent {
|
|
287
|
+
items = ["a", "b"];
|
|
288
|
+
renderItem(item: unknown) {
|
|
289
|
+
return document.createElement("div");
|
|
290
|
+
}
|
|
291
|
+
render() { return null; }
|
|
292
|
+
}
|
|
293
|
+
// render() is called before onMount, so no throw yet — but it still computes total height
|
|
294
|
+
const Wrapped = applyVirtual(ZeroHeightList2 as AnyConstructor, 0);
|
|
295
|
+
const instance = new Wrapped();
|
|
296
|
+
// Render without mounting — no onMount guard triggered, but division happens in computed
|
|
297
|
+
// The guard is in onMount, so render still runs. Verify it doesn't produce NaN/Infinity
|
|
298
|
+
expect(() => instance.render()).not.toThrow();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("renderItem() that throws — does not crash the entire component", () => {
|
|
302
|
+
class ThrowingList extends StatefulComponent {
|
|
303
|
+
items = ["x"];
|
|
304
|
+
renderItem(): Node {
|
|
305
|
+
throw new Error("render item failed");
|
|
306
|
+
}
|
|
307
|
+
render() { return null; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const Wrapped = applyVirtual(ThrowingList as AnyConstructor, 50, 0);
|
|
311
|
+
const instance = new Wrapped();
|
|
312
|
+
// Render triggers the effect which calls renderItem — it should propagate the error
|
|
313
|
+
expect(() => instance.render()).toThrow("render item failed");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("scrollTop > totalHeight renders last visible items without crash", () => {
|
|
317
|
+
class ShortList extends StatefulComponent {
|
|
318
|
+
items = [1, 2, 3]; // only 3 items × 50px = 150px total
|
|
319
|
+
renderItem(item: unknown) {
|
|
320
|
+
const el = document.createElement("div");
|
|
321
|
+
el.textContent = String(item);
|
|
322
|
+
return el;
|
|
323
|
+
}
|
|
324
|
+
render() { return null; }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const Wrapped = applyVirtual(ShortList as AnyConstructor, 50, 0);
|
|
328
|
+
const instance = new Wrapped();
|
|
329
|
+
|
|
330
|
+
const container = document.createElement("div");
|
|
331
|
+
document.body.appendChild(container);
|
|
332
|
+
const anchor = document.createComment("end");
|
|
333
|
+
container.appendChild(anchor);
|
|
334
|
+
(instance as unknown as { _anchor: Comment })._anchor = anchor;
|
|
335
|
+
|
|
336
|
+
instance.onMount?.();
|
|
337
|
+
const outer = instance.render() as HTMLElement;
|
|
338
|
+
|
|
339
|
+
// Simulate scrollTop way past end of list (e.g., 9999px for a 150px list)
|
|
340
|
+
Object.defineProperty(container, "scrollTop", { configurable: true, value: 9999 });
|
|
341
|
+
expect(() => container.dispatchEvent(new Event("scroll"))).not.toThrow();
|
|
342
|
+
|
|
343
|
+
// The outer container should still be present
|
|
344
|
+
expect(outer).toBeInstanceOf(HTMLElement);
|
|
239
345
|
document.body.removeChild(container);
|
|
240
346
|
});
|
|
347
|
+
|
|
348
|
+
it("buffer applied correctly — renders extra items beyond visible area", () => {
|
|
349
|
+
class BufferList extends StatefulComponent {
|
|
350
|
+
items = Array.from({ length: 20 }, (_, i) => i);
|
|
351
|
+
renderItem(item: unknown) {
|
|
352
|
+
const el = document.createElement("div");
|
|
353
|
+
el.textContent = String(item);
|
|
354
|
+
return el;
|
|
355
|
+
}
|
|
356
|
+
render() { return null; }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// itemHeight=100, buffer=2, viewHeight defaults to 600 → visible=6 items + 2 buffer each side
|
|
360
|
+
const Wrapped = applyVirtual(BufferList as AnyConstructor, 100, 2);
|
|
361
|
+
const instance = new Wrapped();
|
|
362
|
+
const outer = instance.render() as HTMLElement;
|
|
363
|
+
|
|
364
|
+
// With scrollTop=0, buffer=2: startIdx=max(0, 0-2)=0, endIdx=min(19, ceil(600/100)+2)=8
|
|
365
|
+
// So 9 items should be rendered (indices 0..8)
|
|
366
|
+
const itemsSlot = outer.children[1] as HTMLElement;
|
|
367
|
+
expect(itemsSlot.children.length).toBe(9);
|
|
368
|
+
});
|
|
241
369
|
});
|
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
import { StatelessComponent } from "@praxisjs/core";
|
|
2
|
+
import type { RootComponent } from "@praxisjs/core/internal";
|
|
2
3
|
import type {
|
|
3
4
|
ComponentConstructor,
|
|
4
5
|
ComponentInstance,
|
|
5
6
|
} from "@praxisjs/shared/internal";
|
|
6
7
|
|
|
8
|
+
import {
|
|
9
|
+
ClassBehavior,
|
|
10
|
+
createClassDecorator,
|
|
11
|
+
type ClassEnhancement,
|
|
12
|
+
} from "../create-class-decorator";
|
|
13
|
+
|
|
14
|
+
class ComponentBehavior extends ClassBehavior {
|
|
15
|
+
create(_instance: RootComponent): ClassEnhancement {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
initialize(Enhanced: new (...args: any[]) => unknown, original: new (...args: any[]) => unknown): void {
|
|
21
|
+
const isStateless = (original.prototype as object) instanceof StatelessComponent;
|
|
22
|
+
Object.defineProperty(Enhanced, "__isComponent", { value: true, configurable: true });
|
|
23
|
+
Object.defineProperty(Enhanced, "__isStateless", { value: isStateless, configurable: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const behavior = new ComponentBehavior();
|
|
28
|
+
|
|
7
29
|
export function Component() {
|
|
8
30
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
31
|
return function <T extends new (...args: any[]) => ComponentInstance>(
|
|
10
32
|
constructor: T,
|
|
11
|
-
|
|
33
|
+
context: ClassDecoratorContext,
|
|
12
34
|
): T & ComponentConstructor {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} as unknown as T & ComponentConstructor;
|
|
18
|
-
|
|
19
|
-
Object.defineProperty(Enhanced, "name", {
|
|
20
|
-
value: constructor.name,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
return Enhanced;
|
|
35
|
+
return createClassDecorator(behavior)(
|
|
36
|
+
constructor as unknown as T & (new(...args: unknown[]) => RootComponent<Record<string, unknown>>),
|
|
37
|
+
context,
|
|
38
|
+
) as unknown as T & ComponentConstructor;
|
|
24
39
|
};
|
|
25
40
|
}
|
package/src/component/lazy.ts
CHANGED
|
@@ -1,60 +1,56 @@
|
|
|
1
1
|
import { type RootComponent, signal } from "@praxisjs/core/internal";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
-
return function <T extends new (...args: any[]) => RootComponent<Record<string, any>>>(
|
|
6
|
-
constructor: T,
|
|
7
|
-
_context: ClassDecoratorContext,
|
|
8
|
-
): T {
|
|
9
|
-
return class LazyWrapper extends constructor {
|
|
10
|
-
private readonly _lazyVisible = signal(false);
|
|
11
|
-
private _observer?: IntersectionObserver;
|
|
12
|
-
private readonly _originalRender: () => Node | Node[] | null =
|
|
13
|
-
// Capture the *parent* class render, not LazyWrapper's own override,
|
|
14
|
-
// to avoid infinite recursion when render() delegates here.
|
|
15
|
-
(constructor.prototype as { render(): Node | Node[] | null }).render.bind(this);
|
|
3
|
+
import { ClassBehavior, createClassDecorator, type ClassEnhancement } from "../create-class-decorator";
|
|
16
4
|
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
class LazyBehavior extends ClassBehavior {
|
|
6
|
+
constructor(private readonly placeholder: number) {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
19
9
|
|
|
20
|
-
|
|
10
|
+
create(instance: RootComponent): ClassEnhancement {
|
|
11
|
+
const placeholder = this.placeholder;
|
|
12
|
+
const visible = signal(false);
|
|
13
|
+
let observer: IntersectionObserver | undefined;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
onMount() {
|
|
21
17
|
const el =
|
|
22
|
-
(
|
|
23
|
-
?.parentElement ?? null;
|
|
18
|
+
(instance as { _anchor?: Comment })._anchor?.parentElement ?? null;
|
|
24
19
|
if (!el) return;
|
|
25
20
|
|
|
26
21
|
if (!("IntersectionObserver" in window)) {
|
|
27
|
-
|
|
22
|
+
visible.set(true);
|
|
28
23
|
return;
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
if (!
|
|
32
|
-
el.style.minHeight = `${String(placeholder)}px`;
|
|
33
|
-
}
|
|
26
|
+
if (!visible()) el.style.minHeight = `${String(placeholder)}px`;
|
|
34
27
|
|
|
35
|
-
|
|
28
|
+
observer = new IntersectionObserver(
|
|
36
29
|
(entries) => {
|
|
37
30
|
if (entries[0]?.isIntersecting) {
|
|
38
|
-
|
|
31
|
+
visible.set(true);
|
|
39
32
|
el.style.minHeight = "";
|
|
40
|
-
|
|
33
|
+
observer?.disconnect();
|
|
41
34
|
}
|
|
42
35
|
},
|
|
43
36
|
{ rootMargin: "100px" },
|
|
44
37
|
);
|
|
45
38
|
|
|
46
|
-
|
|
47
|
-
}
|
|
39
|
+
observer.observe(el);
|
|
40
|
+
},
|
|
48
41
|
|
|
49
42
|
onUnmount() {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
observer?.disconnect();
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
render(originalRender) {
|
|
47
|
+
if (!visible()) return null;
|
|
48
|
+
return originalRender();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function Lazy(placeholder = 200) {
|
|
55
|
+
return createClassDecorator(new LazyBehavior(placeholder));
|
|
60
56
|
}
|
|
@@ -1,81 +1,81 @@
|
|
|
1
1
|
import { computed, effect, type RootComponent, signal } from "@praxisjs/core/internal";
|
|
2
2
|
|
|
3
|
+
import { ClassBehavior, createClassDecorator, type ClassEnhancement } from "../create-class-decorator";
|
|
4
|
+
|
|
3
5
|
interface VirtualHost {
|
|
4
6
|
_anchor?: Comment;
|
|
5
7
|
items?: unknown[];
|
|
6
8
|
renderItem(item: unknown, index: number): Node | Node[] | null;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
class VirtualBehavior extends ClassBehavior {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly itemHeight: number,
|
|
14
|
+
private readonly buffer: number,
|
|
15
|
+
) {
|
|
16
|
+
super();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
create(instance: RootComponent): ClassEnhancement {
|
|
20
|
+
const { itemHeight, buffer } = this;
|
|
21
|
+
const host = instance as unknown as VirtualHost;
|
|
22
|
+
const scrollTop = signal(0);
|
|
23
|
+
const viewHeight = signal(600);
|
|
24
|
+
let container: HTMLElement | undefined;
|
|
25
|
+
let scrollHandler: (() => void) | undefined;
|
|
26
|
+
const cleanups: Array<() => void> = [];
|
|
27
|
+
|
|
28
|
+
return {
|
|
22
29
|
onMount() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!
|
|
30
|
-
|
|
31
|
-
this._container.style.overflowY = "auto";
|
|
32
|
-
this._container.style.position = "relative";
|
|
30
|
+
if (itemHeight <= 0) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`[Virtual] itemHeight must be a positive number, got ${String(itemHeight)}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
container = host._anchor?.parentElement ?? undefined;
|
|
36
|
+
if (!container) return;
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
container.style.overflowY = "auto";
|
|
39
|
+
container.style.position = "relative";
|
|
40
|
+
viewHeight.set(container.clientHeight || 600);
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
if (
|
|
42
|
+
scrollHandler = () => {
|
|
43
|
+
if (container) scrollTop.set(container.scrollTop);
|
|
38
44
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
45
|
+
container.addEventListener("scroll", scrollHandler);
|
|
46
|
+
},
|
|
42
47
|
|
|
43
48
|
onUnmount() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this._container.removeEventListener("scroll", this._scrollHandler);
|
|
49
|
+
if (container && scrollHandler) {
|
|
50
|
+
container.removeEventListener("scroll", scrollHandler);
|
|
47
51
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
52
|
+
cleanups.forEach((c) => { c(); });
|
|
53
|
+
cleanups.length = 0;
|
|
54
|
+
},
|
|
51
55
|
|
|
52
|
-
render() {
|
|
53
|
-
const host = this as unknown as VirtualHost;
|
|
56
|
+
render(originalRender) {
|
|
54
57
|
const items = host.items ?? [];
|
|
55
58
|
const total = items.length;
|
|
56
59
|
const totalH = total * itemHeight;
|
|
57
60
|
|
|
58
61
|
if (typeof host.renderItem !== "function") {
|
|
59
62
|
console.warn(
|
|
60
|
-
`[Virtual]
|
|
63
|
+
`[Virtual] component must implement renderItem(item, index)`,
|
|
61
64
|
);
|
|
62
|
-
return
|
|
65
|
+
return originalRender();
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
const renderItem = host.renderItem.bind(host);
|
|
66
69
|
|
|
67
|
-
const startIdx = computed(() =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return Math.min(total - 1, end);
|
|
77
|
-
});
|
|
78
|
-
|
|
70
|
+
const startIdx = computed(() =>
|
|
71
|
+
Math.max(0, Math.floor(scrollTop() / itemHeight) - buffer),
|
|
72
|
+
);
|
|
73
|
+
const endIdx = computed(() =>
|
|
74
|
+
Math.min(
|
|
75
|
+
total - 1,
|
|
76
|
+
Math.ceil((scrollTop() + viewHeight()) / itemHeight) + buffer,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
79
|
const visibleItems = computed(() => {
|
|
80
80
|
const result: Array<{ item: unknown; index: number }> = [];
|
|
81
81
|
for (let i = startIdx(); i <= endIdx(); i++) {
|
|
@@ -83,31 +83,23 @@ export function Virtual(itemHeight: number, buffer = 3) {
|
|
|
83
83
|
}
|
|
84
84
|
return result;
|
|
85
85
|
});
|
|
86
|
-
|
|
87
86
|
const offsetTop = computed(() => startIdx() * itemHeight);
|
|
88
|
-
const offsetBottom = computed(
|
|
89
|
-
() => (total - 1 - endIdx()) * itemHeight,
|
|
90
|
-
);
|
|
87
|
+
const offsetBottom = computed(() => (total - 1 - endIdx()) * itemHeight);
|
|
91
88
|
|
|
92
89
|
const outer = document.createElement("div");
|
|
93
|
-
outer.setAttribute(
|
|
94
|
-
"style",
|
|
95
|
-
`height:${String(totalH)}px; position:relative;`,
|
|
96
|
-
);
|
|
90
|
+
outer.setAttribute("style", `height:${String(totalH)}px; position:relative;`);
|
|
97
91
|
|
|
98
92
|
const spacerTop = document.createElement("div");
|
|
99
|
-
|
|
93
|
+
cleanups.push(
|
|
100
94
|
effect(() => {
|
|
101
95
|
spacerTop.setAttribute("style", `height:${String(offsetTop())}px;`);
|
|
102
96
|
}),
|
|
103
97
|
);
|
|
104
98
|
|
|
105
99
|
const itemsSlot = document.createElement("div");
|
|
106
|
-
|
|
100
|
+
cleanups.push(
|
|
107
101
|
effect(() => {
|
|
108
|
-
while (itemsSlot.firstChild)
|
|
109
|
-
itemsSlot.removeChild(itemsSlot.firstChild);
|
|
110
|
-
}
|
|
102
|
+
while (itemsSlot.firstChild) itemsSlot.removeChild(itemsSlot.firstChild);
|
|
111
103
|
visibleItems().forEach(({ item, index }) => {
|
|
112
104
|
const wrapper = document.createElement("div");
|
|
113
105
|
wrapper.setAttribute(
|
|
@@ -116,10 +108,9 @@ export function Virtual(itemHeight: number, buffer = 3) {
|
|
|
116
108
|
);
|
|
117
109
|
const rendered = renderItem(item, index);
|
|
118
110
|
if (rendered) {
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
(Array.isArray(rendered) ? rendered.flat() : [rendered]).forEach(
|
|
112
|
+
(n) => { wrapper.appendChild(n); },
|
|
121
113
|
);
|
|
122
|
-
nodes.forEach((n) => wrapper.appendChild(n));
|
|
123
114
|
}
|
|
124
115
|
itemsSlot.appendChild(wrapper);
|
|
125
116
|
});
|
|
@@ -127,12 +118,9 @@ export function Virtual(itemHeight: number, buffer = 3) {
|
|
|
127
118
|
);
|
|
128
119
|
|
|
129
120
|
const spacerBottom = document.createElement("div");
|
|
130
|
-
|
|
121
|
+
cleanups.push(
|
|
131
122
|
effect(() => {
|
|
132
|
-
spacerBottom.setAttribute(
|
|
133
|
-
"style",
|
|
134
|
-
`height:${String(offsetBottom())}px;`,
|
|
135
|
-
);
|
|
123
|
+
spacerBottom.setAttribute("style", `height:${String(offsetBottom())}px;`);
|
|
136
124
|
}),
|
|
137
125
|
);
|
|
138
126
|
|
|
@@ -141,7 +129,11 @@ export function Virtual(itemHeight: number, buffer = 3) {
|
|
|
141
129
|
outer.appendChild(spacerBottom);
|
|
142
130
|
|
|
143
131
|
return outer;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function Virtual(itemHeight: number, buffer = 3) {
|
|
138
|
+
return createClassDecorator(new VirtualBehavior(itemHeight, buffer));
|
|
147
139
|
}
|