@rs-x/cli 2.0.0-next.10 → 2.0.0-next.11
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/bin/rsx.cjs +186 -4
- package/package.json +1 -1
- package/{rs-x-vscode-extension-2.0.0-next.10.vsix → rs-x-vscode-extension-2.0.0-next.11.vsix} +0 -0
- package/templates/react-demo/README.md +113 -0
- package/templates/react-demo/index.html +12 -0
- package/templates/react-demo/src/app/app.tsx +87 -0
- package/templates/react-demo/src/app/hooks/use-virtual-table-controller.ts +24 -0
- package/templates/react-demo/src/app/hooks/use-virtual-table-viewport.ts +39 -0
- package/templates/react-demo/src/app/virtual-table/row-data.ts +35 -0
- package/templates/react-demo/src/app/virtual-table/row-model.ts +45 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-controller.ts +247 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-data.service.ts +126 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-row.tsx +38 -0
- package/templates/react-demo/src/app/virtual-table/virtual-table-shell.tsx +83 -0
- package/templates/react-demo/src/main.tsx +23 -0
- package/templates/react-demo/src/rsx-bootstrap.ts +18 -0
- package/templates/react-demo/src/styles.css +422 -0
- package/templates/react-demo/tsconfig.json +17 -0
package/bin/rsx.cjs
CHANGED
|
@@ -21,6 +21,12 @@ const ANGULAR_DEMO_TEMPLATE_DIR = path.join(
|
|
|
21
21
|
'templates',
|
|
22
22
|
'angular-demo',
|
|
23
23
|
);
|
|
24
|
+
const REACT_DEMO_TEMPLATE_DIR = path.join(
|
|
25
|
+
__dirname,
|
|
26
|
+
'..',
|
|
27
|
+
'templates',
|
|
28
|
+
'react-demo',
|
|
29
|
+
);
|
|
24
30
|
const RUNTIME_PACKAGES = [
|
|
25
31
|
'@rs-x/core',
|
|
26
32
|
'@rs-x/state-manager',
|
|
@@ -680,6 +686,40 @@ function resolveAngularProjectTsConfig(projectRoot) {
|
|
|
680
686
|
return path.join(projectRoot, 'tsconfig.json');
|
|
681
687
|
}
|
|
682
688
|
|
|
689
|
+
function upsertTypescriptPluginInTsConfig(configPath, dryRun) {
|
|
690
|
+
if (!fs.existsSync(configPath)) {
|
|
691
|
+
logWarn(`TypeScript config not found: ${configPath}`);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const tsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
696
|
+
const compilerOptions = tsConfig.compilerOptions ?? {};
|
|
697
|
+
const plugins = Array.isArray(compilerOptions.plugins)
|
|
698
|
+
? compilerOptions.plugins
|
|
699
|
+
: [];
|
|
700
|
+
|
|
701
|
+
if (
|
|
702
|
+
!plugins.some(
|
|
703
|
+
(plugin) =>
|
|
704
|
+
plugin &&
|
|
705
|
+
typeof plugin === 'object' &&
|
|
706
|
+
plugin.name === '@rs-x/typescript-plugin',
|
|
707
|
+
)
|
|
708
|
+
) {
|
|
709
|
+
plugins.push({ name: '@rs-x/typescript-plugin' });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
compilerOptions.plugins = plugins;
|
|
713
|
+
tsConfig.compilerOptions = compilerOptions;
|
|
714
|
+
|
|
715
|
+
if (dryRun) {
|
|
716
|
+
logInfo(`[dry-run] patch ${configPath}`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
fs.writeFileSync(configPath, `${JSON.stringify(tsConfig, null, 2)}\n`, 'utf8');
|
|
721
|
+
}
|
|
722
|
+
|
|
683
723
|
function toFileDependencySpec(fromDir, targetPath) {
|
|
684
724
|
const relative = path.relative(fromDir, targetPath).replace(/\\/gu, '/');
|
|
685
725
|
const normalized = relative.startsWith('.') ? relative : `./${relative}`;
|
|
@@ -729,6 +769,7 @@ function resolveProjectRsxSpecs(
|
|
|
729
769
|
options = {},
|
|
730
770
|
) {
|
|
731
771
|
const includeAngularPackage = Boolean(options.includeAngularPackage);
|
|
772
|
+
const includeReactPackage = Boolean(options.includeReactPackage);
|
|
732
773
|
const versionSpec = options.tag ? options.tag : RSX_PACKAGE_VERSION;
|
|
733
774
|
const defaults = {
|
|
734
775
|
'@rs-x/core': versionSpec,
|
|
@@ -737,6 +778,7 @@ function resolveProjectRsxSpecs(
|
|
|
737
778
|
'@rs-x/compiler': versionSpec,
|
|
738
779
|
'@rs-x/typescript-plugin': versionSpec,
|
|
739
780
|
...(includeAngularPackage ? { '@rs-x/angular': versionSpec } : {}),
|
|
781
|
+
...(includeReactPackage ? { '@rs-x/react': versionSpec } : {}),
|
|
740
782
|
'@rs-x/cli': versionSpec,
|
|
741
783
|
};
|
|
742
784
|
|
|
@@ -747,6 +789,7 @@ function resolveProjectRsxSpecs(
|
|
|
747
789
|
'@rs-x/compiler': 'rs-x-compiler',
|
|
748
790
|
'@rs-x/typescript-plugin': 'rs-x-typescript-plugin',
|
|
749
791
|
...(includeAngularPackage ? { '@rs-x/angular': 'rs-x-angular' } : {}),
|
|
792
|
+
...(includeReactPackage ? { '@rs-x/react': 'rs-x-react' } : {}),
|
|
750
793
|
'@rs-x/cli': 'rs-x-cli',
|
|
751
794
|
};
|
|
752
795
|
|
|
@@ -769,6 +812,11 @@ function resolveProjectRsxSpecs(
|
|
|
769
812
|
'rs-x-angular': path.join(tarballsDir, 'rs-x-angular'),
|
|
770
813
|
}
|
|
771
814
|
: {}),
|
|
815
|
+
...(includeReactPackage
|
|
816
|
+
? {
|
|
817
|
+
'rs-x-react': path.join(tarballsDir, 'rs-x-react'),
|
|
818
|
+
}
|
|
819
|
+
: {}),
|
|
772
820
|
'rs-x-cli': path.join(tarballsDir, 'rs-x-cli'),
|
|
773
821
|
};
|
|
774
822
|
|
|
@@ -812,6 +860,11 @@ function resolveProjectRsxSpecs(
|
|
|
812
860
|
),
|
|
813
861
|
}
|
|
814
862
|
: {}),
|
|
863
|
+
...(includeReactPackage
|
|
864
|
+
? {
|
|
865
|
+
'@rs-x/react': path.join(workspaceRoot, 'rs-x-react'),
|
|
866
|
+
}
|
|
867
|
+
: {}),
|
|
815
868
|
'@rs-x/cli': path.join(workspaceRoot, 'rs-x-cli'),
|
|
816
869
|
};
|
|
817
870
|
|
|
@@ -1454,6 +1507,138 @@ function applyAngularDemoStarter(projectRoot, projectName, pm, flags) {
|
|
|
1454
1507
|
}
|
|
1455
1508
|
}
|
|
1456
1509
|
|
|
1510
|
+
function applyReactDemoStarter(projectRoot, projectName, pm, flags) {
|
|
1511
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
1512
|
+
const tag = resolveInstallTag(flags);
|
|
1513
|
+
const tarballsDir =
|
|
1514
|
+
typeof flags['tarballs-dir'] === 'string'
|
|
1515
|
+
? path.resolve(process.cwd(), flags['tarballs-dir'])
|
|
1516
|
+
: typeof process.env.RSX_TARBALLS_DIR === 'string' &&
|
|
1517
|
+
process.env.RSX_TARBALLS_DIR.trim().length > 0
|
|
1518
|
+
? path.resolve(process.cwd(), process.env.RSX_TARBALLS_DIR)
|
|
1519
|
+
: null;
|
|
1520
|
+
const workspaceRoot = findRepoRoot(projectRoot);
|
|
1521
|
+
const rsxSpecs = resolveProjectRsxSpecs(
|
|
1522
|
+
projectRoot,
|
|
1523
|
+
workspaceRoot,
|
|
1524
|
+
tarballsDir,
|
|
1525
|
+
{ tag, includeReactPackage: true },
|
|
1526
|
+
);
|
|
1527
|
+
|
|
1528
|
+
const templateFiles = [
|
|
1529
|
+
'README.md',
|
|
1530
|
+
'index.html',
|
|
1531
|
+
'src',
|
|
1532
|
+
'tsconfig.json',
|
|
1533
|
+
'vite.config.ts',
|
|
1534
|
+
];
|
|
1535
|
+
for (const entry of templateFiles) {
|
|
1536
|
+
copyPathWithDryRun(
|
|
1537
|
+
path.join(REACT_DEMO_TEMPLATE_DIR, entry),
|
|
1538
|
+
path.join(projectRoot, entry),
|
|
1539
|
+
dryRun,
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
const staleReactFiles = [
|
|
1544
|
+
path.join(projectRoot, 'src/App.tsx'),
|
|
1545
|
+
path.join(projectRoot, 'src/App.css'),
|
|
1546
|
+
path.join(projectRoot, 'src/index.css'),
|
|
1547
|
+
path.join(projectRoot, 'src/vite-env.d.ts'),
|
|
1548
|
+
path.join(projectRoot, 'src/assets'),
|
|
1549
|
+
path.join(projectRoot, 'public'),
|
|
1550
|
+
path.join(projectRoot, 'eslint.config.js'),
|
|
1551
|
+
path.join(projectRoot, 'eslint.config.ts'),
|
|
1552
|
+
path.join(projectRoot, 'tsconfig.app.json'),
|
|
1553
|
+
path.join(projectRoot, 'tsconfig.node.json'),
|
|
1554
|
+
];
|
|
1555
|
+
for (const stalePath of staleReactFiles) {
|
|
1556
|
+
removeFileOrDirectoryWithDryRun(stalePath, dryRun);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const readmePath = path.join(projectRoot, 'README.md');
|
|
1560
|
+
if (fs.existsSync(readmePath)) {
|
|
1561
|
+
const readmeSource = fs.readFileSync(readmePath, 'utf8');
|
|
1562
|
+
const nextReadme = readmeSource.replace(
|
|
1563
|
+
/^#\s+rsx-react-example/mu,
|
|
1564
|
+
`# ${projectName}`,
|
|
1565
|
+
);
|
|
1566
|
+
if (dryRun) {
|
|
1567
|
+
logInfo(`[dry-run] patch ${readmePath}`);
|
|
1568
|
+
} else {
|
|
1569
|
+
fs.writeFileSync(readmePath, nextReadme, 'utf8');
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
1574
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
1575
|
+
logError(`package.json not found in generated React app: ${packageJsonPath}`);
|
|
1576
|
+
process.exit(1);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
1580
|
+
packageJson.name = projectName;
|
|
1581
|
+
packageJson.private = true;
|
|
1582
|
+
packageJson.version = '0.1.0';
|
|
1583
|
+
packageJson.type = 'module';
|
|
1584
|
+
packageJson.scripts = {
|
|
1585
|
+
'build:rsx': 'rsx build --project tsconfig.json --no-emit --prod',
|
|
1586
|
+
dev: 'npm run build:rsx && vite',
|
|
1587
|
+
build: 'npm run build:rsx && vite build',
|
|
1588
|
+
preview: 'vite preview',
|
|
1589
|
+
};
|
|
1590
|
+
packageJson.rsx = {
|
|
1591
|
+
build: {
|
|
1592
|
+
preparse: true,
|
|
1593
|
+
preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
|
|
1594
|
+
compiled: true,
|
|
1595
|
+
compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
|
|
1596
|
+
compiledResolvedEvaluator: false,
|
|
1597
|
+
},
|
|
1598
|
+
};
|
|
1599
|
+
packageJson.dependencies = {
|
|
1600
|
+
react: packageJson.dependencies?.react ?? '^19.2.4',
|
|
1601
|
+
'react-dom': packageJson.dependencies?.['react-dom'] ?? '^19.2.4',
|
|
1602
|
+
'@rs-x/core': rsxSpecs['@rs-x/core'],
|
|
1603
|
+
'@rs-x/state-manager': rsxSpecs['@rs-x/state-manager'],
|
|
1604
|
+
'@rs-x/expression-parser': rsxSpecs['@rs-x/expression-parser'],
|
|
1605
|
+
'@rs-x/react': rsxSpecs['@rs-x/react'],
|
|
1606
|
+
};
|
|
1607
|
+
packageJson.devDependencies = {
|
|
1608
|
+
typescript: packageJson.devDependencies?.typescript ?? '^5.9.3',
|
|
1609
|
+
vite: packageJson.devDependencies?.vite ?? '^7.3.1',
|
|
1610
|
+
'@vitejs/plugin-react':
|
|
1611
|
+
packageJson.devDependencies?.['@vitejs/plugin-react'] ?? '^5.1.4',
|
|
1612
|
+
'@types/react': packageJson.devDependencies?.['@types/react'] ?? '^19.2.2',
|
|
1613
|
+
'@types/react-dom':
|
|
1614
|
+
packageJson.devDependencies?.['@types/react-dom'] ?? '^19.2.2',
|
|
1615
|
+
'@rs-x/cli': rsxSpecs['@rs-x/cli'],
|
|
1616
|
+
'@rs-x/compiler': rsxSpecs['@rs-x/compiler'],
|
|
1617
|
+
'@rs-x/typescript-plugin': rsxSpecs['@rs-x/typescript-plugin'],
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
if (dryRun) {
|
|
1621
|
+
logInfo(`[dry-run] patch ${packageJsonPath}`);
|
|
1622
|
+
} else {
|
|
1623
|
+
fs.writeFileSync(
|
|
1624
|
+
packageJsonPath,
|
|
1625
|
+
`${JSON.stringify(packageJson, null, 2)}\n`,
|
|
1626
|
+
'utf8',
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const tsConfigPath = path.join(projectRoot, 'tsconfig.json');
|
|
1631
|
+
if (fs.existsSync(tsConfigPath)) {
|
|
1632
|
+
upsertTypescriptPluginInTsConfig(tsConfigPath, dryRun);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (!Boolean(flags['skip-install'])) {
|
|
1636
|
+
logInfo(`Refreshing ${pm} dependencies for the RS-X React starter...`);
|
|
1637
|
+
run(pm, ['install'], { dryRun });
|
|
1638
|
+
logOk('React starter dependencies are up to date.');
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1457
1642
|
async function runProjectWithTemplate(template, flags) {
|
|
1458
1643
|
const normalizedTemplate = normalizeProjectTemplate(template);
|
|
1459
1644
|
if (!normalizedTemplate) {
|
|
@@ -1489,10 +1674,7 @@ async function runProjectWithTemplate(template, flags) {
|
|
|
1489
1674
|
return;
|
|
1490
1675
|
}
|
|
1491
1676
|
if (normalizedTemplate === 'react') {
|
|
1492
|
-
|
|
1493
|
-
...flags,
|
|
1494
|
-
entry: flags.entry ?? 'src/main.tsx',
|
|
1495
|
-
});
|
|
1677
|
+
applyReactDemoStarter(projectRoot, projectName, pm, flags);
|
|
1496
1678
|
return;
|
|
1497
1679
|
}
|
|
1498
1680
|
if (normalizedTemplate === 'nextjs') {
|
package/package.json
CHANGED
package/{rs-x-vscode-extension-2.0.0-next.10.vsix → rs-x-vscode-extension-2.0.0-next.11.vsix}
RENAMED
|
Binary file
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# rsx-react-example
|
|
2
|
+
|
|
3
|
+
React demo app for RS-X.
|
|
4
|
+
|
|
5
|
+
**Website & docs:** [rsxjs.com](https://www.rsxjs.com/)
|
|
6
|
+
|
|
7
|
+
This example shows a million-row virtual table that:
|
|
8
|
+
|
|
9
|
+
- uses the `useRsxExpression` hook from `@rs-x/react`
|
|
10
|
+
- creates row expressions with `rsx(...)`
|
|
11
|
+
- keeps a fixed pool of row models and expressions
|
|
12
|
+
- loads pages on demand while scrolling
|
|
13
|
+
- keeps memory bounded by reusing the row pool and pruning old page data
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cd rsx-react-example
|
|
19
|
+
npm install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm run dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`npm run dev` first runs the RS-X build step, then starts Vite.
|
|
29
|
+
|
|
30
|
+
## Build
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm run build
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This runs:
|
|
37
|
+
|
|
38
|
+
1. `rsx build --project tsconfig.json --no-emit --prod`
|
|
39
|
+
2. `vite build`
|
|
40
|
+
|
|
41
|
+
So the example gets:
|
|
42
|
+
|
|
43
|
+
- RS-X semantic checks
|
|
44
|
+
- generated AOT RS-X caches
|
|
45
|
+
- React production build output
|
|
46
|
+
|
|
47
|
+
## Basic RS-X React setup
|
|
48
|
+
|
|
49
|
+
The example uses the normal React RS-X setup:
|
|
50
|
+
|
|
51
|
+
### 1. Initialize RS-X before rendering React
|
|
52
|
+
|
|
53
|
+
In `src/rsx-bootstrap.ts`:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { InjectionContainer } from '@rs-x/core';
|
|
57
|
+
import { RsXExpressionParserModule } from '@rs-x/expression-parser';
|
|
58
|
+
|
|
59
|
+
export async function initRsx(): Promise<void> {
|
|
60
|
+
await InjectionContainer.load(RsXExpressionParserModule);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. Create expressions with `rsx(...)`
|
|
65
|
+
|
|
66
|
+
In `src/app/virtual-table/row-model.ts`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
idExpr: rsx<number>('id')(model),
|
|
70
|
+
nameExpr: rsx<string>('name')(model),
|
|
71
|
+
totalExpr: rsx<number>('price * quantity')(model),
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Bind them with `useRsxExpression`
|
|
75
|
+
|
|
76
|
+
In `src/app/virtual-table/virtual-table-row.tsx`:
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
const total = useRsxExpression(row.totalExpr);
|
|
80
|
+
return <span>{total}</span>;
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Why this example is useful
|
|
84
|
+
|
|
85
|
+
The point of the demo is not just rendering a table. It shows how RS-X behaves in a realistic React scenario:
|
|
86
|
+
|
|
87
|
+
- large logical dataset: `1,000,000` rows
|
|
88
|
+
- small live expression pool: only the pooled row models stay active
|
|
89
|
+
- page loading is async to simulate real server requests
|
|
90
|
+
- old loaded pages are pruned so scrolling does not grow memory forever
|
|
91
|
+
|
|
92
|
+
## About the React integration in this demo
|
|
93
|
+
|
|
94
|
+
This example uses `useRsxExpression` directly in row components so the RS-X behavior is easy to see.
|
|
95
|
+
|
|
96
|
+
That is a demo choice, not a restriction.
|
|
97
|
+
|
|
98
|
+
In a real React app, you can also bridge RS-X values into other React-friendly state shapes such as `useSyncExternalStore`, memoized selectors, or your preferred state container if that fits better.
|
|
99
|
+
|
|
100
|
+
## Key files
|
|
101
|
+
|
|
102
|
+
- `src/main.tsx`
|
|
103
|
+
- `src/rsx-bootstrap.ts`
|
|
104
|
+
- `src/app/app.tsx`
|
|
105
|
+
- `src/app/virtual-table/virtual-table-shell.tsx`
|
|
106
|
+
- `src/app/virtual-table/virtual-table-row.tsx`
|
|
107
|
+
- `src/app/virtual-table/virtual-table-controller.ts`
|
|
108
|
+
- `src/app/virtual-table/virtual-table-data.service.ts`
|
|
109
|
+
- `src/app/virtual-table/row-model.ts`
|
|
110
|
+
|
|
111
|
+
## Notes
|
|
112
|
+
|
|
113
|
+
- The virtual table uses a bounded pool and bounded page retention on purpose, so performance characteristics stay visible while memory stays under control.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>RS-X React Demo</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type FC, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { VirtualTableShell } from './virtual-table/virtual-table-shell';
|
|
4
|
+
|
|
5
|
+
type ThemeMode = 'light' | 'dark';
|
|
6
|
+
|
|
7
|
+
function getInitialTheme(): ThemeMode {
|
|
8
|
+
const storedTheme = window.localStorage.getItem('rsx-theme');
|
|
9
|
+
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
10
|
+
return storedTheme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return 'dark';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const App: FC = () => {
|
|
17
|
+
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
21
|
+
document.body.setAttribute('data-theme', theme);
|
|
22
|
+
window.localStorage.setItem('rsx-theme', theme);
|
|
23
|
+
}, [theme]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<main className="app-shell">
|
|
27
|
+
<section className="hero">
|
|
28
|
+
<div className="container">
|
|
29
|
+
<div className="heroGrid">
|
|
30
|
+
<div className="heroLeft">
|
|
31
|
+
<p className="app-eyebrow">RS-X React Demo</p>
|
|
32
|
+
<h1 className="hTitle">Virtual Table</h1>
|
|
33
|
+
<p className="hSubhead">
|
|
34
|
+
Million-row scrolling with a fixed RS-X expression pool.
|
|
35
|
+
</p>
|
|
36
|
+
<p className="hSub">
|
|
37
|
+
This demo keeps rendering bounded while streaming pages on demand,
|
|
38
|
+
so scrolling stays smooth without growing expression memory with the
|
|
39
|
+
dataset.
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<div className="heroActions">
|
|
43
|
+
<a
|
|
44
|
+
className="btn btnGhost"
|
|
45
|
+
href="https://www.rsxjs.com/"
|
|
46
|
+
target="_blank"
|
|
47
|
+
rel="noreferrer"
|
|
48
|
+
>
|
|
49
|
+
rs-x
|
|
50
|
+
</a>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
className="btn btnGhost theme-toggle"
|
|
54
|
+
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
55
|
+
onClick={() => {
|
|
56
|
+
setTheme((currentTheme) =>
|
|
57
|
+
currentTheme === 'dark' ? 'light' : 'dark',
|
|
58
|
+
);
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<aside className="card heroNote">
|
|
67
|
+
<h2 className="cardTitle">What This Shows</h2>
|
|
68
|
+
<p className="cardText">
|
|
69
|
+
Only a small row-model pool stays alive while pages stream in
|
|
70
|
+
around the viewport. That means one million logical rows without
|
|
71
|
+
one million live bindings.
|
|
72
|
+
</p>
|
|
73
|
+
</aside>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section className="section">
|
|
79
|
+
<div className="container">
|
|
80
|
+
<section className="app-panel card">
|
|
81
|
+
<VirtualTableShell />
|
|
82
|
+
</section>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
</main>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
VirtualTableController,
|
|
5
|
+
type VirtualTableSnapshot,
|
|
6
|
+
} from '../virtual-table/virtual-table-controller';
|
|
7
|
+
|
|
8
|
+
export function useVirtualTableController(): {
|
|
9
|
+
controller: VirtualTableController;
|
|
10
|
+
snapshot: VirtualTableSnapshot;
|
|
11
|
+
} {
|
|
12
|
+
const controllerRef = useRef<VirtualTableController | null>(null);
|
|
13
|
+
if (!controllerRef.current) {
|
|
14
|
+
controllerRef.current = new VirtualTableController();
|
|
15
|
+
}
|
|
16
|
+
const controller = controllerRef.current;
|
|
17
|
+
const snapshot = useSyncExternalStore(
|
|
18
|
+
controller.subscribe,
|
|
19
|
+
controller.getSnapshot,
|
|
20
|
+
controller.getSnapshot,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return { controller, snapshot };
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { VirtualTableController } from '../virtual-table/virtual-table-controller';
|
|
4
|
+
|
|
5
|
+
const COMPACT_BREAKPOINT_PX = 720;
|
|
6
|
+
const DEFAULT_ROW_HEIGHT = 36;
|
|
7
|
+
const COMPACT_ROW_HEIGHT = 168;
|
|
8
|
+
|
|
9
|
+
export function useVirtualTableViewport(
|
|
10
|
+
controller: VirtualTableController,
|
|
11
|
+
): RefObject<HTMLDivElement | null> {
|
|
12
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const viewport = viewportRef.current;
|
|
16
|
+
if (!viewport) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const syncMetrics = (): void => {
|
|
21
|
+
controller.setViewportHeight(viewport.clientHeight);
|
|
22
|
+
controller.setRowHeight(
|
|
23
|
+
viewport.clientWidth <= COMPACT_BREAKPOINT_PX
|
|
24
|
+
? COMPACT_ROW_HEIGHT
|
|
25
|
+
: DEFAULT_ROW_HEIGHT,
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
syncMetrics();
|
|
30
|
+
const observer = new ResizeObserver(syncMetrics);
|
|
31
|
+
observer.observe(viewport);
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
observer.disconnect();
|
|
35
|
+
};
|
|
36
|
+
}, [controller]);
|
|
37
|
+
|
|
38
|
+
return viewportRef;
|
|
39
|
+
}
|
|
@@ -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 { rsx, type IExpression } 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
|
+
}
|