diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/App.tsx b/packages/core/test/fixtures/gea-elements-virtual-list/App.tsx
new file mode 100644
index 0000000..8e16996
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-virtual-list/App.tsx
@@ -0,0 +1,12 @@
+import { Component } from '@geastack/core'
+import { Rows } from './Rows'
+
+export class App extends Component {
+ template() {
+ return (
+
+
+
+ )
+ }
+}
diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/Rows.tsx b/packages/core/test/fixtures/gea-elements-virtual-list/Rows.tsx
new file mode 100644
index 0000000..fc7a794
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-virtual-list/Rows.tsx
@@ -0,0 +1,27 @@
+import { Store, type VirtualListElement } from '@geastack/core'
+import { VirtualList } from '@geastack/elements'
+
+// The @geastack/elements VirtualList used the way its README shows: the app
+// keeps scrollTop in a Store and copies the element's offset back on scroll.
+class RowsStore extends Store {
+ scrollTop = 0
+}
+
+const rows = new RowsStore()
+
+export function Rows() {
+ return (
+ {'Row ' + index}}
+ onScroll={() => {
+ const list = document.getElementById('rows') as VirtualListElement | null
+ if (list) rows.scrollTop = list.scrollTop
+ }}
+ />
+ )
+}
diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/index.tsx b/packages/core/test/fixtures/gea-elements-virtual-list/index.tsx
new file mode 100644
index 0000000..b83d2c3
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-virtual-list/index.tsx
@@ -0,0 +1,5 @@
+import { mount } from '@geastack/core'
+import { App } from './App'
+import './styles.css'
+
+mount(App)
diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/styles.css b/packages/core/test/fixtures/gea-elements-virtual-list/styles.css
new file mode 100644
index 0000000..b1d09d7
--- /dev/null
+++ b/packages/core/test/fixtures/gea-elements-virtual-list/styles.css
@@ -0,0 +1,13 @@
+.app {
+ width: 100vw;
+ height: 100vh;
+}
+
+.rows {
+ width: 100vw;
+ height: 400px;
+}
+
+.row-label {
+ font-size: 16px;
+}
diff --git a/packages/core/test/run-gea-elements-virtual-list-pipeline.sh b/packages/core/test/run-gea-elements-virtual-list-pipeline.sh
new file mode 100755
index 0000000..06522da
--- /dev/null
+++ b/packages/core/test/run-gea-elements-virtual-list-pipeline.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+exec "$ROOT/packages/core/test/run-gea-native-app-pipeline.sh" \
+ "gea-elements-virtual-list-pipeline" \
+ "$ROOT/packages/core/test/fixtures/gea-elements-virtual-list" \
+ "index.tsx" \
+ "packages/core/test/test_gea_elements_virtual_list_main.cpp"
diff --git a/packages/core/test/test_gea_elements_virtual_list_main.cpp b/packages/core/test/test_gea_elements_virtual_list_main.cpp
new file mode 100644
index 0000000..bca7244
--- /dev/null
+++ b/packages/core/test/test_gea_elements_virtual_list_main.cpp
@@ -0,0 +1,95 @@
+#include "native_test_harness.h"
+
+#include "app.h"
+#include "ui/tree_internal.h"
+
+#include
+#include
+#include
+#include
+
+extern void __gea_top_level();
+
+namespace {
+constexpr int kViewportWidth = 720;
+constexpr int kViewportHeight = 1440;
+constexpr double kDevicePixelRatio = 2.0;
+constexpr const char *kTest = "test_gea_elements_virtual_list_main";
+// VirtualList renders a fixed pool of SLOT_COUNT (VirtualList.tsx) slots.
+constexpr int kSlotCount = 16;
+
+// The row indexes the visible labels show. nodesWithClass skips hidden nodes,
+// so slots past the end of the list are not counted.
+std::vector visibleRowIndexes()
+{
+ std::vector out;
+ for (const int id : gea::embedded::test::nodesWithClass("row-label")) {
+ const std::string text = gea::embedded::test::textContent(id);
+ if (text.rfind("Row ", 0) != 0) continue;
+ out.push_back(std::atoi(text.c_str() + 4));
+ }
+ return out;
+}
+
+// Every slot shows exactly one row, and together they show rows
+// first..first+15 with no gap or repeat.
+bool expectRows(const char *phase, int first)
+{
+ const auto indexes = visibleRowIndexes();
+ std::vector seen(kSlotCount, false);
+ bool ok = indexes.size() == static_cast(kSlotCount);
+ for (const int index : indexes) {
+ const int offset = index - first;
+ if (offset < 0 || offset >= kSlotCount || seen[offset]) ok = false;
+ else seen[offset] = true;
+ }
+ if (!ok) {
+ std::fprintf(stderr, "[%s] %s: expected rows %d..%d once each, got %zu labels:", kTest, phase, first, first + kSlotCount - 1, indexes.size());
+ for (const int index : indexes) std::fprintf(stderr, " %d", index);
+ std::fprintf(stderr, "\n");
+ }
+ return ok;
+}
+} // namespace
+
+int main()
+{
+ using namespace gea::embedded::test;
+ auto &tree = gea::embedded::ui::Tree::instance();
+
+ resetNativeHost();
+ setNativeDisplaySize(kViewportWidth, kViewportHeight);
+ gea::framework::app::Application::init(kViewportWidth, kViewportHeight, kDevicePixelRatio);
+ __gea_top_level();
+ refresh();
+ gea::framework::app::Application::frame(16);
+
+ const auto lists = nodesWithType(gea::embedded::ui::NodeType::VirtualList);
+ if (lists.size() != 1) {
+ std::fprintf(stderr, "[%s] expected one virtual-list node, got %zu\n", kTest, lists.size());
+ dumpTree(kTest);
+ return 1;
+ }
+ const auto &list = tree.node(lists[0]);
+
+ // The rows are absolutely positioned, so the list's height comes only from
+ // the class the app passes.
+ const int expectedHeight = static_cast(400 * kDevicePixelRatio);
+ bool ok = true;
+ if (list.layout.height != expectedHeight) {
+ std::fprintf(stderr, "[%s] list height %d, expected %d from .rows\n", kTest, list.layout.height, expectedHeight);
+ ok = false;
+ }
+
+ // The pool shows the first 16 rows, each from itemTemplate.
+ ok = ok && expectRows("initial", 0);
+
+ // Scrolling is not checked here: a scrollTop change re-renders VirtualList
+ // and replaces its node, which resets the native offset.
+
+ if (!ok) {
+ dumpTree(kTest);
+ return 1;
+ }
+ return 0;
+}
diff --git a/packages/elements/components/VirtualList.d.ts b/packages/elements/components/VirtualList.d.ts
index 814c041..558436e 100644
--- a/packages/elements/components/VirtualList.d.ts
+++ b/packages/elements/components/VirtualList.d.ts
@@ -1,6 +1,7 @@
-import type { ClassValue, Event, TouchEventHandler } from '@geastack/core'
+import type { ClassValue, Event, GeaJsxElement, TouchEventHandler } from '@geastack/core'
-export type VirtualListItemTemplate = (index: number, top: number) => any
+/** Renders row `index`, placed at `top`. Return an element; wrap plain text in one, such as a ``. */
+export type VirtualListItemTemplate = (index: number, top: number) => GeaJsxElement
export interface VirtualListProps {
itemCount: number
diff --git a/packages/elements/components/VirtualList.tsx b/packages/elements/components/VirtualList.tsx
index 1ca393d..d701dda 100644
--- a/packages/elements/components/VirtualList.tsx
+++ b/packages/elements/components/VirtualList.tsx
@@ -1,4 +1,4 @@
-import type { ClassValue, Event, TouchEventHandler } from '@geastack/core'
+import type { ClassValue, Event, GeaJsxElement, TouchEventHandler } from '@geastack/core'
// A windowing list backed by the native element. The element is
// an ordinary overflow:scroll container that scrolls over a virtual content
@@ -10,7 +10,7 @@ import type { ClassValue, Event, TouchEventHandler } from '@geastack/core'
// blit instead of re-rendering. Row content, height, colours and fonts all come
// from `itemTemplate` + its CSS; the framework owns none of it.
-export type VirtualListItemTemplate = (index: number, top: number) => any
+export type VirtualListItemTemplate = (index: number, top: number) => GeaJsxElement
export interface VirtualListProps {
itemCount: number
diff --git a/packages/elements/components/index.ts b/packages/elements/components/index.ts
index 992a63c..705bad0 100644
--- a/packages/elements/components/index.ts
+++ b/packages/elements/components/index.ts
@@ -1,2 +1,3 @@
export { CameraView } from './CameraView'
export { default as VirtualList } from './VirtualList'
+export type { VirtualListProps, VirtualListItemTemplate } from './VirtualList'