@rs-x/cli 2.0.0-next.5 → 2.0.0-next.7

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.
@@ -0,0 +1,58 @@
1
+ <main class="app-shell">
2
+ <section class="hero">
3
+ <div class="container">
4
+ <div class="heroGrid">
5
+ <div class="heroLeft">
6
+ <p class="app-eyebrow">RS-X Angular Demo</p>
7
+ <h1 class="hTitle">Virtual Table</h1>
8
+ <p class="hSubhead">
9
+ Million-row scrolling with a fixed RS-X expression pool.
10
+ </p>
11
+ <p class="hSub">
12
+ This demo keeps rendering bounded while streaming pages on demand,
13
+ so scrolling stays smooth without growing expression memory with the
14
+ dataset.
15
+ </p>
16
+
17
+ <div class="heroActions">
18
+ <a
19
+ class="btn btnGhost"
20
+ href="https://www.rsxjs.com/"
21
+ target="_blank"
22
+ rel="noreferrer"
23
+ >
24
+ rs-x
25
+ </a>
26
+ <button
27
+ type="button"
28
+ class="btn btnGhost theme-toggle"
29
+ (click)="toggleTheme()"
30
+ [attr.aria-label]="
31
+ 'Switch to ' + (theme === 'dark' ? 'light' : 'dark') + ' mode'
32
+ "
33
+ >
34
+ {{ theme === 'dark' ? 'Light mode' : 'Dark mode' }}
35
+ </button>
36
+ </div>
37
+ </div>
38
+
39
+ <aside class="card heroNote">
40
+ <h2 class="cardTitle">What This Shows</h2>
41
+ <p class="cardText">
42
+ Only a small row-model pool stays alive while pages stream in around
43
+ the viewport. That means one million logical rows without one
44
+ million live bindings.
45
+ </p>
46
+ </aside>
47
+ </div>
48
+ </div>
49
+ </section>
50
+
51
+ <section class="section">
52
+ <div class="container">
53
+ <section class="app-panel card">
54
+ <rsx-virtual-table></rsx-virtual-table>
55
+ </section>
56
+ </div>
57
+ </section>
58
+ </main>
@@ -0,0 +1,52 @@
1
+ import { DOCUMENT } from '@angular/common';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ HostBinding,
6
+ inject,
7
+ OnInit,
8
+ } from '@angular/core';
9
+
10
+ import { VirtualTableComponent } from './virtual-table/virtual-table.component';
11
+
12
+ type ThemeMode = 'light' | 'dark';
13
+
14
+ @Component({
15
+ selector: 'app-root',
16
+ standalone: true,
17
+ imports: [VirtualTableComponent],
18
+ templateUrl: './app.component.html',
19
+ styleUrls: ['./app.component.css'],
20
+ changeDetection: ChangeDetectionStrategy.OnPush,
21
+ })
22
+ export class AppComponent implements OnInit {
23
+ private readonly _document = inject(DOCUMENT);
24
+ @HostBinding('class.theme-dark') public isDarkTheme = false;
25
+ public theme: ThemeMode = 'light';
26
+
27
+ public ngOnInit(): void {
28
+ this.theme = this.getInitialTheme();
29
+ this.applyTheme(this.theme);
30
+ }
31
+
32
+ public toggleTheme(): void {
33
+ this.theme = this.theme === 'dark' ? 'light' : 'dark';
34
+ this.applyTheme(this.theme);
35
+ window.localStorage.setItem('rsx-theme', this.theme);
36
+ }
37
+
38
+ private getInitialTheme(): ThemeMode {
39
+ const storedTheme = window.localStorage.getItem('rsx-theme');
40
+ if (storedTheme === 'light' || storedTheme === 'dark') {
41
+ return storedTheme;
42
+ }
43
+
44
+ return 'light';
45
+ }
46
+
47
+ private applyTheme(theme: ThemeMode): void {
48
+ this.isDarkTheme = theme === 'dark';
49
+ this._document.documentElement.setAttribute('data-theme', theme);
50
+ this._document.body.setAttribute('data-theme', theme);
51
+ }
52
+ }
@@ -0,0 +1,35 @@
1
+ export type SortKey = 'id' | 'name' | 'price' | 'quantity' | 'category';
2
+ export type SortDirection = 'asc' | 'desc';
3
+
4
+ export type RowData = {
5
+ id: number;
6
+ name: string;
7
+ price: number;
8
+ quantity: number;
9
+ category: string;
10
+ updatedAt: string;
11
+ };
12
+
13
+ const categories = ['Hardware', 'Software', 'Design', 'Ops'];
14
+
15
+ function pad(value: number): string {
16
+ return value.toString().padStart(2, '0');
17
+ }
18
+
19
+ export function createRowData(id: number): RowData {
20
+ const zeroBasedId = id - 1;
21
+ const price = 25 + (zeroBasedId % 1000) / 10;
22
+ const quantity = 1 + (Math.floor(zeroBasedId / 1000) % 100);
23
+ const category = categories[zeroBasedId % categories.length] ?? 'General';
24
+ const month = pad(((zeroBasedId * 7) % 12) + 1);
25
+ const day = pad(((zeroBasedId * 11) % 28) + 1);
26
+
27
+ return {
28
+ id,
29
+ name: `Product ${id.toString().padStart(7, '0')}`,
30
+ price,
31
+ quantity,
32
+ category,
33
+ updatedAt: `2026-${month}-${day}`,
34
+ };
35
+ }
@@ -0,0 +1,45 @@
1
+ import { type IExpression, rsx } from '@rs-x/expression-parser';
2
+
3
+ import type { RowData } from './row-data';
4
+
5
+ export type RowModel = {
6
+ model: RowData;
7
+ idExpr: IExpression<number>;
8
+ nameExpr: IExpression<string>;
9
+ categoryExpr: IExpression<string>;
10
+ priceExpr: IExpression<number>;
11
+ quantityExpr: IExpression<number>;
12
+ updatedAtExpr: IExpression<string>;
13
+ totalExpr: IExpression<number>;
14
+ };
15
+
16
+ export function createRowModel(): RowModel {
17
+ const model: RowData = {
18
+ id: 0,
19
+ name: '',
20
+ price: 0,
21
+ quantity: 0,
22
+ category: 'General',
23
+ updatedAt: '2026-01-01',
24
+ };
25
+
26
+ return {
27
+ model,
28
+ idExpr: rsx<number>('id')(model),
29
+ nameExpr: rsx<string>('name')(model),
30
+ categoryExpr: rsx<string>('category')(model),
31
+ priceExpr: rsx<number>('price')(model),
32
+ quantityExpr: rsx<number>('quantity')(model),
33
+ updatedAtExpr: rsx<string>('updatedAt')(model),
34
+ totalExpr: rsx<number>('price * quantity')(model),
35
+ };
36
+ }
37
+
38
+ export function updateRowModel(target: RowModel, data: RowData): void {
39
+ target.model.id = data.id;
40
+ target.model.name = data.name;
41
+ target.model.price = data.price;
42
+ target.model.quantity = data.quantity;
43
+ target.model.category = data.category;
44
+ target.model.updatedAt = data.updatedAt;
45
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ createRowData,
3
+ type RowData,
4
+ type SortDirection,
5
+ type SortKey,
6
+ } from './row-data';
7
+
8
+ export type VirtualTablePage = {
9
+ total: number;
10
+ items: RowData[];
11
+ };
12
+
13
+ const TOTAL_ROWS = 1_000_000;
14
+ const REQUEST_DELAY_MS = 120;
15
+ const CATEGORY_COUNT = 4;
16
+ const PRICE_BUCKET_COUNT = 1_000;
17
+ const QUANTITY_BUCKET_COUNT = 100;
18
+ const MAX_CACHED_PAGES = 24;
19
+
20
+ export class VirtualTableDataService {
21
+ private readonly _pageCache = new Map<string, VirtualTablePage>();
22
+
23
+ public get totalRows(): number {
24
+ return TOTAL_ROWS;
25
+ }
26
+
27
+ public async fetchPage(
28
+ pageIndex: number,
29
+ pageSize: number,
30
+ sortKey: SortKey,
31
+ sortDirection: SortDirection,
32
+ ): Promise<VirtualTablePage> {
33
+ const cacheKey = `${sortKey}:${sortDirection}:${pageIndex}:${pageSize}`;
34
+ const cached = this._pageCache.get(cacheKey);
35
+ if (cached) {
36
+ return cached;
37
+ }
38
+
39
+ await this.delay(REQUEST_DELAY_MS + (pageIndex % 5) * 35);
40
+
41
+ const startIndex = pageIndex * pageSize;
42
+ const items: RowData[] = [];
43
+ const endIndex = Math.min(startIndex + pageSize, TOTAL_ROWS);
44
+
45
+ for (
46
+ let visualIndex = startIndex;
47
+ visualIndex < endIndex;
48
+ visualIndex += 1
49
+ ) {
50
+ const id = this.getIdAtVisualIndex(visualIndex, sortKey, sortDirection);
51
+ items.push(createRowData(id));
52
+ }
53
+
54
+ const page = {
55
+ total: TOTAL_ROWS,
56
+ items,
57
+ };
58
+ this._pageCache.set(cacheKey, page);
59
+ this.trimCache();
60
+ return page;
61
+ }
62
+
63
+ private getIdAtVisualIndex(
64
+ visualIndex: number,
65
+ sortKey: SortKey,
66
+ sortDirection: SortDirection,
67
+ ): number {
68
+ const normalizedIndex =
69
+ sortDirection === 'asc' ? visualIndex : TOTAL_ROWS - 1 - visualIndex;
70
+
71
+ if (sortKey === 'price') {
72
+ return this.getPriceSortedId(normalizedIndex);
73
+ }
74
+
75
+ if (sortKey === 'quantity') {
76
+ return this.getQuantitySortedId(normalizedIndex);
77
+ }
78
+
79
+ if (sortKey === 'category') {
80
+ return this.getCategorySortedId(normalizedIndex);
81
+ }
82
+
83
+ // Name follows id order because names are deterministic from the row id.
84
+ return normalizedIndex + 1;
85
+ }
86
+
87
+ private getPriceSortedId(visualIndex: number): number {
88
+ const groupSize = TOTAL_ROWS / PRICE_BUCKET_COUNT;
89
+ const priceBucket = Math.floor(visualIndex / groupSize);
90
+ const offsetInBucket = visualIndex % groupSize;
91
+
92
+ return priceBucket + offsetInBucket * PRICE_BUCKET_COUNT + 1;
93
+ }
94
+
95
+ private getQuantitySortedId(visualIndex: number): number {
96
+ const groupSize = TOTAL_ROWS / QUANTITY_BUCKET_COUNT;
97
+ const quantityBucket = Math.floor(visualIndex / groupSize);
98
+ const offsetInBucket = visualIndex % groupSize;
99
+ const quantityStride = PRICE_BUCKET_COUNT * QUANTITY_BUCKET_COUNT;
100
+ const quantityBlock = Math.floor(offsetInBucket / PRICE_BUCKET_COUNT);
101
+ const priceBucket = offsetInBucket % PRICE_BUCKET_COUNT;
102
+
103
+ return (
104
+ priceBucket +
105
+ quantityBucket * PRICE_BUCKET_COUNT +
106
+ quantityBlock * quantityStride +
107
+ 1
108
+ );
109
+ }
110
+
111
+ private getCategorySortedId(visualIndex: number): number {
112
+ const groupSize = TOTAL_ROWS / CATEGORY_COUNT;
113
+ const categoryBucket = Math.floor(visualIndex / groupSize);
114
+ const offsetInBucket = visualIndex % groupSize;
115
+
116
+ return categoryBucket + offsetInBucket * CATEGORY_COUNT + 1;
117
+ }
118
+
119
+ private delay(durationMs: number): Promise<void> {
120
+ return new Promise((resolve) => {
121
+ window.setTimeout(resolve, durationMs);
122
+ });
123
+ }
124
+
125
+ private trimCache(): void {
126
+ while (this._pageCache.size > MAX_CACHED_PAGES) {
127
+ const oldestKey = this._pageCache.keys().next().value as
128
+ | string
129
+ | undefined;
130
+ if (!oldestKey) {
131
+ return;
132
+ }
133
+ this._pageCache.delete(oldestKey);
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,224 @@
1
+ import { type IExpression, rsx } from '@rs-x/expression-parser';
2
+
3
+ import { type RowData, type SortDirection, type SortKey } from './row-data';
4
+ import { createRowModel, type RowModel, updateRowModel } from './row-model';
5
+ import { VirtualTableDataService } from './virtual-table-data.service';
6
+
7
+ export type RowView = {
8
+ index: number;
9
+ top: number;
10
+ row: RowModel;
11
+ };
12
+
13
+ const ROW_HEIGHT = 36;
14
+ const PAGE_SIZE = 50;
15
+ const POOL_PAGES = 4;
16
+ const CACHE_PADDING_PAGES = 2;
17
+ const RETAIN_PADDING_PAGES = 4;
18
+
19
+ export class VirtualTableModel {
20
+ public rowHeight = ROW_HEIGHT;
21
+ public readonly pageSize = PAGE_SIZE;
22
+ public readonly poolSize = PAGE_SIZE * POOL_PAGES;
23
+ public readonly totalRows: number;
24
+
25
+ public scrollTop = 0;
26
+ public viewportHeight = 480;
27
+ public sortKey: SortKey = 'id';
28
+ public sortDirection: SortDirection = 'asc';
29
+ public spacerHeight: number;
30
+ public rowsInView = Math.max(
31
+ 1,
32
+ Math.ceil(this.viewportHeight / this.rowHeight),
33
+ );
34
+ public visibleRows: RowView[] = [];
35
+ public readonly rowsExpression: IExpression<RowView[]>;
36
+
37
+ private readonly rowsModel = {
38
+ rows: [] as RowView[],
39
+ };
40
+ private readonly pool = Array.from({ length: this.poolSize }, () =>
41
+ createRowModel(),
42
+ );
43
+ private readonly dataByIndex = new Map<number, RowData>();
44
+ private readonly loadedPages = new Set<number>();
45
+ private readonly pageLoading = new Map<number, Promise<void>>();
46
+ private readonly dataService = new VirtualTableDataService();
47
+
48
+ public constructor() {
49
+ this.totalRows = this.dataService.totalRows;
50
+ this.spacerHeight = this.totalRows * this.rowHeight;
51
+ this.rowsExpression = rsx<RowView[]>('rows')(this.rowsModel);
52
+ this.refresh();
53
+ }
54
+
55
+ public get loadedPageCount(): number {
56
+ return this.loadedPages.size;
57
+ }
58
+
59
+ public setViewportHeight(height: number): void {
60
+ this.viewportHeight = height;
61
+ this.rowsInView = Math.max(
62
+ 1,
63
+ Math.ceil(this.viewportHeight / this.rowHeight),
64
+ );
65
+ this.refresh();
66
+ }
67
+
68
+ public setRowHeight(height: number): void {
69
+ if (this.rowHeight === height) {
70
+ return;
71
+ }
72
+
73
+ this.rowHeight = height;
74
+ this.spacerHeight = this.totalRows * this.rowHeight;
75
+ this.rowsInView = Math.max(
76
+ 1,
77
+ Math.ceil(this.viewportHeight / this.rowHeight),
78
+ );
79
+ this.refresh();
80
+ }
81
+
82
+ public setScrollTop(value: number): void {
83
+ this.scrollTop = value;
84
+ this.refresh();
85
+ }
86
+
87
+ public toggleSort(nextKey: SortKey): void {
88
+ if (this.sortKey === nextKey) {
89
+ this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
90
+ } else {
91
+ this.sortKey = nextKey;
92
+ this.sortDirection = 'asc';
93
+ }
94
+
95
+ this.resetLoadedData();
96
+ this.refresh();
97
+ }
98
+
99
+ private refresh(): void {
100
+ const scrollIndex = Math.floor(this.scrollTop / this.rowHeight);
101
+ const bufferTop = Math.max(
102
+ 0,
103
+ Math.floor((this.poolSize - this.rowsInView) / 2),
104
+ );
105
+ const maxStart = Math.max(0, this.totalRows - this.poolSize);
106
+ const startIndex = Math.min(Math.max(scrollIndex - bufferTop, 0), maxStart);
107
+ const endIndex = Math.min(startIndex + this.poolSize, this.totalRows);
108
+ const startPage = Math.max(
109
+ 0,
110
+ Math.floor(startIndex / this.pageSize) - CACHE_PADDING_PAGES,
111
+ );
112
+ const endPage = Math.min(
113
+ Math.floor((endIndex - 1) / this.pageSize) + CACHE_PADDING_PAGES,
114
+ Math.floor((this.totalRows - 1) / this.pageSize),
115
+ );
116
+
117
+ this.ensurePages(startPage, endPage);
118
+ this.pruneCachedPages(startPage, endPage);
119
+
120
+ const nextRows: RowView[] = [];
121
+ const length = endIndex - startIndex;
122
+
123
+ for (let offset = 0; offset < length; offset += 1) {
124
+ const index = startIndex + offset;
125
+ const target = this.pool[offset];
126
+ updateRowModel(target, this.getRowData(index));
127
+
128
+ nextRows.push({
129
+ index,
130
+ top: index * this.rowHeight,
131
+ row: target,
132
+ });
133
+ }
134
+
135
+ this.rowsModel.rows = nextRows;
136
+ this.visibleRows = nextRows;
137
+ }
138
+
139
+ private ensurePages(startPage: number, endPage: number): void {
140
+ for (let pageIndex = startPage; pageIndex <= endPage; pageIndex += 1) {
141
+ this.ensurePageLoaded(pageIndex);
142
+ }
143
+ }
144
+
145
+ private ensurePageLoaded(pageIndex: number): void {
146
+ if (this.loadedPages.has(pageIndex) || this.pageLoading.has(pageIndex)) {
147
+ return;
148
+ }
149
+
150
+ const task = this.loadPageAsync(pageIndex).finally(() => {
151
+ this.pageLoading.delete(pageIndex);
152
+ this.loadedPages.add(pageIndex);
153
+ this.refresh();
154
+ });
155
+
156
+ this.pageLoading.set(pageIndex, task);
157
+ }
158
+
159
+ private async loadPageAsync(pageIndex: number): Promise<void> {
160
+ const page = await this.dataService.fetchPage(
161
+ pageIndex,
162
+ this.pageSize,
163
+ this.sortKey,
164
+ this.sortDirection,
165
+ );
166
+ const startIndex = pageIndex * this.pageSize;
167
+
168
+ for (let offset = 0; offset < page.items.length; offset += 1) {
169
+ const item = page.items[offset];
170
+ if (!item) {
171
+ continue;
172
+ }
173
+
174
+ this.dataByIndex.set(startIndex + offset, item);
175
+ }
176
+ }
177
+
178
+ private getRowData(index: number): RowData {
179
+ const cached = this.dataByIndex.get(index);
180
+ if (cached) {
181
+ return cached;
182
+ }
183
+
184
+ return this.buildPlaceholderRow(index);
185
+ }
186
+
187
+ private buildPlaceholderRow(index: number): RowData {
188
+ return {
189
+ id: index + 1,
190
+ name: 'Loading...',
191
+ price: 0,
192
+ quantity: 0,
193
+ category: 'Pending',
194
+ updatedAt: '--',
195
+ };
196
+ }
197
+
198
+ private resetLoadedData(): void {
199
+ this.dataByIndex.clear();
200
+ this.loadedPages.clear();
201
+ this.pageLoading.clear();
202
+ }
203
+
204
+ private pruneCachedPages(startPage: number, endPage: number): void {
205
+ const minPage = Math.max(0, startPage - RETAIN_PADDING_PAGES);
206
+ const maxPage = Math.min(
207
+ Math.floor((this.totalRows - 1) / this.pageSize),
208
+ endPage + RETAIN_PADDING_PAGES,
209
+ );
210
+
211
+ for (const pageIndex of Array.from(this.loadedPages)) {
212
+ if (pageIndex >= minPage && pageIndex <= maxPage) {
213
+ continue;
214
+ }
215
+
216
+ this.loadedPages.delete(pageIndex);
217
+ const pageStart = pageIndex * this.pageSize;
218
+ const pageEnd = Math.min(pageStart + this.pageSize, this.totalRows);
219
+ for (let index = pageStart; index < pageEnd; index += 1) {
220
+ this.dataByIndex.delete(index);
221
+ }
222
+ }
223
+ }
224
+ }