Skip to content

feat: a Swift client for Zero (iOS and macOS) - #1

Draft
tmm wants to merge 90 commits into
mainfrom
swift-client
Draft

tmm wants to merge 90 commits into
mainfrom
swift-client

Conversation

@tmm

@tmm tmm commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

A Swift client for Zero on iOS and macOS, with parity to the JS client, checked against conformance vectors generated from the JS implementation.

Draft: the last milestone (V5, zbugs-swift on the virtual list) is finished on a Mac but not pushed yet. It will land on this branch before this is ready.

What's here

  • swift/: the Zero Swift package. It has the client (connection, mutations, queries and IVM), ZeroUI (@ZeroQuery for SwiftUI), ZeroVirtual (virtual lists: VirtualList for SwiftUI and VirtualListView for UIKit and AppKit, a counterpart of @rocicorp/zero-virtual), ZeroTesting, and TestHost, a test app for the virtual lists.
  • packages/zero-conformance: vectors generated from the JS client and ZQL, which the Swift tests replay.
  • packages/zero-swift-cli: generates Swift schema, query and mutator types from a Zero schema.
  • apps/zbugs-swift: zbugs as a SwiftUI app for iPhone, iPad and macOS, with its own backend for CI.

Changes outside those directories

  • New workflows: swift.yml (Linux build and tests, the conformance vectors, end-to-end tests), swift-macos.yml (macOS, the iOS simulator and the zbugs build), zbugs-screenshots.yml (zbugs and virtual-list UI tests in simulators and on macOS), and swift-mirror.yml (mirrors swift/ to wevm/zero-swift, which doesn't exist yet).
  • .github/actions/playwright-install: the browser cache is now keyed by the OS release too. ubuntu-latest moved to Ubuntu 26.04, and a WebKit cached on 24.04 failed Public Test on main at b39e85c.
  • apps/zbugs/api/index.ts: the GitHub login callback now redirects to zbugs-swift://auth?jwt=… when that is the requested redirect, because the app's web authentication session can't read the cookie. Please review this: any app can register a custom URL scheme, so a universal link or PKCE-style exchange may be preferable before this goes live.
  • apps/zbugs/scripts/dev-token.ts (pnpm dev-token <userID> [crew|user]): prints a development JWT for clients that can't log in through GitHub. The package.json and tsconfig.scripts.json changes register it.
  • Formatter ignores for generated Swift, .agents/setup for orbs, and pnpm-lock.yaml.

Testing

All of this runs in CI. The virtual list's per-frame probe reports 0 wrong frames on a physical iPhone 15 Pro, the iOS simulator and macOS.

ampagent and others added 30 commits October 19, 2026 04:45
Record the scoping decisions for a native Swift Zero client on iOS 26
and macOS 26: architecture, API, storage, lifecycle, conformance suite,
drift harness, distribution, and milestones M0-M8.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the swift/ package with Zero, ZeroUI, and ZeroTesting targets, the
private zero-conformance package that emits vectors from the JS
implementation, and workflows for Linux, macOS, and the SwiftPM mirror.
The orb setup installs the Swift 6.2.4 toolchain.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the Swift JSONValue model with a JSON.parse-compatible parser and a
JSON.stringify-compatible serializer, JS number formatting and parsing,
UTF-8 and UTF-16 string orders, xxHash32 (h32/h64/h128), the AST with
normalization and query hashing, value comparison, primary-key storage
keys, LIKE/ILIKE, and TTL. Each is checked against vectors that the
zero-conformance package generates from the JS implementation; seeded
fuzz corpora are generated in CI and shared with the Swift job.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…uilder

Port the client IVM engine 1:1 from packages/zql: MemorySource with its
B+tree indexes and push overlays, Filter/Exists/FanOut/FanIn, Join,
FlippedJoin, UnionFanOut/In, Take, Skip, Cap, TakeGate, the pipeline
builder with correlated predicate pushdown, ArrayView change application,
and the untyped AnyQuery builder.

The zero-conformance package gains IVM vectors generated by running the JS
builder and engine: 34 curated programs and a seeded fuzz corpus, each
replaying a push sequence and recording the view and the changes it
receives. Swift replays the same programs through its builder and engine
and must match the AST, format, view, and changes at every step.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add Swift types for every upstream and downstream message of
packages/zero-protocol, with their bodies, the error kind, origin and
reason enums, binary poke chunk decoding, the Sec-WebSocket-Protocol
encoding, and normalizeClientSchema. Decoding accepts and rejects what
valita does in passthrough mode. The types drop unknown keys, as strip
mode does.

Valita behaviors that the types reproduce:
- Upstream is an open tagged tuple union. A message with an unknown
  string tag decodes to .unrecognized, kept as sent. Downstream is
  closed.
- ASTs sent by clients use strictASTSchema: no unknown keys, and at most
  50 levels deep. ASTs from zero-cache are lenient.
- Mutation results follow the JS client: it parses in passthrough mode
  and then checks 'error' in result. Valita returns just {data} once
  data is present, so a result with data is OK even when it also has an
  error key.
- pushResponse tries the OK variant first. jsonObjectSchema (rows patch
  merge) also accepts arrays.
- Inspect bodies are checked by a small valita interpreter and kept as
  stripped JSON.

The new zero-conformance generator emits vectors for downstream and
upstream messages (hand-written, seeded random, and randomly edited),
poke chunks split at seeded byte boundaries, sec protocols, and client
schema normalization. Larger fuzz corpora go to generated/.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Node 24.22's V8 misreads the escaped key in `{"\n":2}` as a backslash once it
has parsed an object keyed by a backslash, so the poke-chunk fuzz corpus CI
generated on Node 24 held wrong expectations. Generate vectors with the Node
in .nvmrc, and refuse to emit vectors on a runtime whose JSON.parse fails the
probe.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the transport protocol the engine talks through, the sync connect URL,
client-to-server name mapping, and the client schema the handshake sends.

A Linux end-to-end suite connects to zero-cache over a test-only SwiftNIO
WebSocket transport, asks for a named query, and checks the rows it receives
through binary poke chunks. packages/zero-conformance/src/e2e runs the server
side: a Postgres database loaded from a fixture, an API server for the app's
named queries and mutators, and zero-cache from source. CI runs it on the
Ubuntu runner's Postgres.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the typed layer over AnyQuery: generated row structs conform to ZeroRow
and name their columns and relationships by key path, Query<Row, Result>
builds with key paths and a `$0.column >= value` expression DSL, and views
decode into the row structs. Column values go through ZeroValue, with string
enums, integer columns, and typed JSON objects.

packages/zero-swift-cli is the `zero-swift codegen` command. It loads the TS
schema, reads enumerations and json<T>() types with the TypeScript checker,
and emits the row structs, their enum and JSON types, the ZeroSchema, and the
`zql` builder. The Swift tests compile the Swift it generates for the zql
test schema and a fixture that covers keywords, reserved names, shared enums,
nested JSON, integer overrides, compound keys, and two-hop relationships; CI
checks the checked-in output is current.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Runs 127 scenarios (connection, errors, pokes, queries, mutations)
against TestZero over a MockSocket with fake timers and memory storage,
and records what the app and the server observe after every step. The
Swift client replays them in M4.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Port the zero-client engine: the connection run loop and state machine,
the replica with its pending mutations and rebase, the pusher, the query
manager with TTLs and got queries, the poke handler, the mutation tracker,
auth and recovery. It runs synchronously on the main actor: an event loop
reproduces JS microtask order, timers come from an injected Scheduler, and
sockets are callback-based. ZeroTesting gains ManualScheduler (fake timers)
and MockSocket.

The public API is Zero<Context> with defineQuery/defineMutator; mutator
bodies are plain throws. A view is destroyed when its last reference goes.

All 127 client scenario vectors from the JS client pass.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The client harness gains `restart`: close the client and start a new one on
the same in-memory storage, optionally later or with other options or
mutators. New scenarios cover what a relaunched client starts from (rows,
cookie, got queries, pending mutations of earlier clients), client group
selection by mutator names, storage names, client GC with deleteClients, and
the server errors that disable a client group or drop the store.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The replica is stored per JS database name in SQLite (or in process memory
with ZeroStorage.memory): client groups, clients and their heartbeats,
deleted clients, and each group's snapshot and pending mutations. A new
client joins the group with its mutator names and replays the pending
mutations; writes go through on a background queue in order. Heartbeats,
client and client group GC, deleteClients, collecting unused databases,
disabling a group or dropping the store on server errors, and the memory
fallback with onStorageFailure follow Replicache.

All 144 client scenarios pass on memory and on SQLite. Definition overloads
now let a context-free app write `(tx, args)`; NoArgs marks a no-argument
definition that reads the context.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The harness stubs document with one whose visibility a step sets, and passes
hiddenTabDisconnectDelay through, so the client's background and foreground
behavior is recorded from the JS client like the rest.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
- Logging: LogSink and logLevel (default warn) at the JS client's log points;
  os.Logger on Apple platforms, stderr elsewhere.
- Views: snapshots with row diffs (listener and AsyncStream); LiveQuery, an
  @observable typed query; Connection is @observable with updateNeeded.
- Recovery: without onClientStateNotFound, Zero replaces its client in place
  and moves views, live queries, and preloads to it.
- Visibility: disconnect in the background, reconnect in the foreground
  (hiddenTabDisconnectDelay semantics); reconnect without the backoff when the
  network is back; wait for pending mutations before suspension.
- Apple: URLSessionTransport (the default socket factory), the UIKit/AppKit
  lifecycle and NWPathMonitor hooks, a SwiftUI @ZeroQuery and environment, and
  a diffable data source adapter.
- Transport frames are written by one task, so they keep their order.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
DynamicProperty is not main-actor isolated, so ZeroQuery conforms with
@preconcurrency; AppKit snapshots reload changed rows, having no reconfigure.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Syncs a named query, runs mutations optimistically until the API server
confirms them, sees them from a second client, and relaunches on the same
SQLite store. The test schema is generated for the E2E target too.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
zero-swift drift record runs an app's TS queries and mutators on samples
in offline clients and records their ASTs, outcomes, and row changes.
Drift.failures (ZeroTesting) replays the record against the Swift port.

Query gains a partial-row start, a where that takes nil as JS takes
undefined, and and/or over optional conditions; escapeLike is ported.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
apps/zbugs-swift ports apps/zbugs/shared to the Swift client and checks
the port against the TS definitions on 37 query and 38 mutator samples.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The drift recorder runs each mutator sample again with the clock a day
later and fails when the runs differ. DESIGN.md records the M6 and M7
decisions.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…port

zbugs' Postgres allows nulls in columns its schema says can't be null, and
JS shows those rows. Swift failed to decode them, which emptied the whole
view. A null in a non-optional String, Double, Int, or Bool column now
reads as "", 0, or false; enums and JSON types still fail.

NIOTransport moves from the E2E tests into a ZeroNIOTesting product, so
apps can run their own end-to-end tests on Linux, where corelibs'
URLSessionWebSocketTask never completes a connection.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The GitHub login sets the JWT as a cookie, which the Swift app's web
authentication session can't read. With redirect=zbugs-swift://auth the
API redirects there with the JWT in the query instead.

`pnpm dev-token <userID> [crew|user]` signs a JWT with the dev key, for
clients that can't log in through GitHub in development and tests.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
ZBugsUI has the app's screens: the issue list with zbugs' filters and
sorts, issue detail with comments, and creating, editing, closing, and
commenting, all through the ported queries and mutators. The list also
comes in UIKit and AppKit through DiffableAdapter, picked in the sidebar.

Login takes a zbugs JWT, from the GitHub login through a web
authentication session or from `pnpm dev-token`, and keeps it in the
keychain. App/ is an XcodeGen project with one multiplatform target, which
macOS CI builds for the iOS simulator and macOS.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
server/server.ts runs zbugs' backend from this repository's sources: a
fresh database with zbugs' migrations and seed data, the API server, and
zero-cache. BackendTests lists, creates, and comments through it, so the
API server runs the TS mutators and queries next to the Swift ones, and
checks that a second client sees the result and that both sides report
zbugs' errors alike. The Linux end-to-end job runs it.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
- The mutation tracker found a mutation by scanning the outstanding ones,
  so mutations slowed down as they piled up (229 µs each over a second of
  them, now 21 µs). It keeps them by ID, as the JS Map does.
- A live query read every row again on every change. It now reuses the
  rows of unchanged entries, matched in order (92 µs instead of about
  640 µs per update of a 1000-row list).
- B-tree nodes skip dynamic exclusivity checks (the tree copies shared
  nodes before writing them), and the forward iterator reads a leaf's keys
  once rather than per element: a 1000-element scan went from 23 µs to
  2.7 µs, and views hydrate faster. The tree is inlinable, so other
  modules get it specialized.
- A view's hydration takes its row list out once, not once per row.
- UTF-8 string comparison and code unit equality compare bytes.
- Reading a column is inlinable and specialized into the row structs.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
ZeroBenchmarks ports zero.bench.ts and the shared B-tree and hash
benchmarks under their names, and adds the Swift client's own: memory for
100k rows, hydration of up to 100k rows (as structs too), pokes, and
mutations including the client's side of a round trip.
`swift run -c release ZeroBenchmarks [filter…]`.

packages/zero-conformance has the JS side of those that have one
(`pnpm --filter zero-conformance bench`). BENCHMARKS.md has both sets of
numbers from one machine: Swift is faster on all the client benchmarks and
B-tree scans, and slower on B-tree lookups.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The IVM operators, wire-protocol bodies, B-tree, hashes, and JSON
machinery were public, so apps (and the docs) saw ~1,700 symbols. They
are package now: the package's tests, ZeroTesting, and the benchmarks
still see them; apps see what they and the generated code use (~720).

ErroredQuery and escapeLike stay public (views report the one; zbugs
uses the other). zbugs reads JWT claims with JSONDecoder instead of the
engine's parser.

A package type with @inlinable members has to be @usableFromInline, or
other modules quietly get unspecialized calls: BTreeSet<Int> scans from
the benchmarks were 5x slower until it was.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
ampagent and others added 30 commits October 20, 2026 02:52
…ottom

Only the user's scrolls measured whether the list was at its bottom, so
scrolling to the latest row with scrollToItem(align: .end) after
scrolling up left following off. Landing now measures it too, as
zero-virtual's stick-to-bottom does on every scroll.

The test app's rows take their padded frame as their accessibility
shape (the UI tests measure it), and the AppleTests case expects the
row where the virtualizer's window puts it, after the space standing in
for rows before the window. Both found on the iPhone and the Mac.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…query to complete

On macOS CI it stopped when the last row arrived, before its query was
confirmed.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The expectation counted rows from the list's start, but under a
permalink anchor the window's indexes count from its anchor. The scroll
view was where it should be (checked on a Mac).

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Reading each row element's identifier and frame was a round trip to the
device, about 6 s per call on an iPhone. The issues test now swipes
until it reaches row 400 (about 14 rows a swipe), up to 60 swipes.
Found on the iPhone.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
… off twice

ScrollGeometry.containerSize already leaves out the content insets, and
ScrollGeometryHost took them off again, so under a navigation bar the
viewport was one inset short (113 pt on an iPhone): rows aligned wrong
and the visible range and the gap to the bottom were off. The adapter
now passes the full height (visibleRect). Found on an iPhone simulator
and the iPhone, which checked the fix.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
To keep what's on screen in place as rows above it loaded, went, or
measured another height, the core scrolled by the difference. On iOS,
ScrollPosition.scrollTo stops a fling, and growth that landed between
the request and the next geometry report was never compensated (an
80 pt jump on the iPhone's chat).

The space before the rows now takes up the difference instead, so the
scroll view isn't touched while it may be moving. New rows carry the
space through paging's renumbering, and a permalink window gets a
reserve to take changes out of. Space that falls short of its estimate
is given back with a scroll once the list has been still for 150 ms;
space over it stays until the rows reach the start. Near the start,
where the space can't go below zero, the core still scrolls.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
SwiftUI reports a row's frame only when it changes. ScrollGeometryHost
dropped every frame when the space before the rows changed, so rows
that didn't move were never measured again and the update waited for
good: on the iPhone, the chat stopped at its first page (found by the
device check). It now keeps them, shifted by the space's change when the
first row is the same; over a new first row the core sized the space to
keep them in place.

The tests' SwiftUI fake now reports only frames that change, and a row's
first frame when it comes into the window, as SwiftUI does.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
From the iPhone's traces at 25def68:

- A list opened at a permalink kept its reserve of space only while its
  anchor was the permalink. Once paging renumbered the window, the space
  estimated one row, and every older page of the chat fell back to a
  scroll that stopped the fling. The reserve now stays until the list
  reaches its start: its indexes count from where it opened.
- Reaching the start dropped the space left over from above at once,
  scrolling to make up for it mid-fling, and the scroll was lost (a
  143 pt jump). The space now stands before the first row until the list
  rests, then is given back.
- ScrollGeometryHost took any report after a requested scroll as the
  scroll landing. A fling's deceleration writing over the scroll left the
  rows where the core didn't think they were (a 2,300 pt jump). A
  contradicted scroll is now asked for again, against the content as it
  is, for up to three reports.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
… to wait

On macOS CI every test target runs in one process, and the fling test's
seconds of main-actor work kept VirtualListTests' real window from
loading within its 5 s wait (found from the CI logs on a Mac). The fling
test moves in coarser frames (about 2.5x cheaper; it still fails on the
reserve regression it guards), and the window test waits up to 30 s.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
… first message

A swipe covers about four messages, and the list no longer skips any on
the way up, so 40 swipes stopped short of m0000 on the iPhone.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
… removes

Removing leftover space at rest used to wait for the rows to report and
compensate a pass later, so the view drew one pass of the shorter content
at the old offset: a visible flash at the top of the chat. The scroll now
goes out in the same update as the new space. Space put back still
compensates after layout, as the view can't scroll past content it
doesn't have yet.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
… it removes

This reverts 9e2e1fe. On the iPhone, SwiftUI applies the shorter content
before the scroll, so the wrong pass stayed and landing took a retry
(19 ms instead of 7-9 ms); on macOS CI the chat showed no rows after a
swipe. The fix moves to the native scroll view.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…tual list

With -probe, TestHost's rows carry marker views, and on every display
frame (CADisplayLink; NSView.displayLink on macOS) the probe reads where
they are on screen. A frame is wrong when the rows move by anything but
the scroll: a drive the probe makes itself must move them by exactly its
step, a finger's momentum must stay smooth, nothing may move at rest, and
a jump (Latest) may show only where the list was or where it lands.

-probe-script chat|issues has the app drive the transitions itself:
older pages of many heights loading, the space given back at each rest
and at the top, and Latest after scrolling far up, with and without a
message received. FrameProbeUITests runs the scripts and real flings
(iOS) and asserts no wrong frame and full-length flings. TestHost now
runs at 120 Hz on ProMotion iPhones.

At bf41176 the probe fails every transition: on the iPhone the issues
page-up has 40 wrong frames and the chat's older pages 40, far up 20.
When rows above the anchor change height mid-scroll, the core takes the
change up in the space before the rows, and the next layout moves them
back. Until then the rows aren't where paging reads them, so evaluating
paging in the same update could move the window to rows that only
seemed to be on screen. On macOS that swung the chat between two windows
forever (m0171…, m0182…), one update each way, until AppKit gave up on
the layout.

An update that respaces the rows now leaves paging to the next update,
which reads the rows where they are.
ScrollPosition.scrollTo(y:) lands at least one frame after the content
changes, so every compensation (a page loading above, space given back,
Latest) showed a frame with the rows moved and the offset not yet. Setting
the offset on the SwiftUI ScrollView's own UIScrollView/NSScrollView
didn't help: SwiftUI lays the content out in its own pass, and the probe
still found 55 wrong frames in the chat on the iPhone and 9 on macOS
Issues.

VirtualList now draws into a UIScrollView (iOS) or NSScrollView (macOS)
that lays out the snapshot's rows itself. Each row is its own hosting
controller, measured with sizeThatFits. LayoutHost runs the core inside
the scroll view's layout pass: it reports the user's scroll, places the
rows, and runs afterUpdate until the snapshot stops changing, so the
rows' frames, the content size and the offset are applied together
before the frame is drawn. Rows stay hidden until the first layout that
isn't on its way to a jump or restore. VirtualList's API is unchanged,
but rows no longer inherit the list's custom environment values: copying
the whole environment into a hosting controller replaces its own, and
the rows lose their accessibility. They get the platform's traits, and
custom values can be applied in the row closure.

With the frame probe at the same transitions, wrong frames go from 40
(Issues) and 161 (chat) to 0 on the iPhone (Release), and from 62
(Issues) and 681 (chat) to 0 on macOS. Fling lengths are unchanged
(about 2.4 s / 575 pt per UI-test fling, before and after).
… chat at its latest message

The probe's summary now counts frames while the list moves, and those
that came late, and carries the last wrong frames' reasons for a failing
test's message. Its DONE line printed the summary before the last
counts were in.

A finger's momentum is checked over pairs of frames, not single ones:
near the end of a fling UIKit moves the content every other frame, and
after a late frame it catches up in one step, which the single-frame
check called a jump. It still finds the jumps bf41176 makes under real
flings.

The chat screen shows its list once it knows the latest message, which
the list opens at: shown before, it drew the top of the chat for a
frame.
…he top

Flung up to the top of Issues, the first page arriving at the start left
space before i0000 (up to 49 pt), carried over from the estimate. The
fling or its bounce came to rest at offset 0 with the gap showing, and
150 ms later the give-back took the space out and i0000 jumped up by it.

On a host that applies a scroll and a snapshot in one layout pass
(VirtualHost.scrollsWithLayout, LayoutHost), space left over at the
start now comes out at once, with a scroll by as much in the same pass:
the rows stay where they are on screen and nothing stands above the
first row. Momentum carries on through the scroll. Hosts that can't
(ScrollGeometryHost) keep it until rest, as before.

The frame probe now flags a frame at rest with a gap above the list's
first row, and no longer reads a bounce past the top as uneven
momentum. testIssuesFlingsToTheTopLeaveNoGap flings to the top from six
distances at four speeds: on the iPhone at ce30a2f it found 23 wrong
frames (a 49 pt gap at rest for 150 ms, then the jump), and 0 now.
…olls

The macOS scroll view aligned each row with backingAlignedRect, which
aligns in the window: as the content scrolled by a fraction of a pixel,
each row snapped anew and moved by up to a pixel against it. At 1x (CI's
display) the frame probe counted that as 1254 wrong frames in the chat
and 555 on Issues ("moved 1.0, the drive 0.2"). Rows are now rounded to
whole pixels in content coordinates, as on iOS, so they move with the
content exactly. Run at 1x (NSHighResolutionCapable off), the probe went
from 172 wrong frames on Issues to 0, and 0 on the chat.
…CI runs each suite in its own job

A jump (Latest) settled half a second after it last moved, counting from
when it began. On the iOS simulator, Latest from the top takes longer
than that to load its rows, so the jump settled where it began and its
landing was checked as a frame at rest ("no row in both frames ... was
m0000"). It now waits for its first move, for up to 5 s; any frame
between where it was and where it lands still fails.

The virtual list UI tests timed out at 45 minutes with the probe's
scripts on both platforms in one job; each platform and suite now runs
in its own.
xcodebuild booted each simulator itself, and the first app launch on one
still starting up timed out (TestHost at 461a9b4, zbugs on the iPad at
ce30a2f). Each now waits for simctl bootstatus first; the screenshots job
runs one simulator at a time.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Each row is hosted in its own hosting view, cut off from the list's
environment, so a @ZeroQuery in a row had no client and stayed empty
without an error. Rows now get the list's .zero(_:) client.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…'s frame timing

On the iOS simulator, real-fling tests failed the momentum check in their
first frames ("speed 4590 between 10413.9 and 4290"), while no row was out
of place: the scroll offset and the rows moved together in every frame.
The simulator's display link fires on time, but its callbacks come late
by varying amounts, so a frame's sample can be one frame ahead of its
timestamp, and the next pair's speed spikes. Under a fast fling,
deceleration alone also moves a pair's speed by more than the fixed 300
pt/s band.

A pair's speed is now compared with the mean of its neighbors', within
300 pt/s or 15 % of it, and frames within two of one whose callback came
more than half a frame later than the last are skipped, as late frames
already were. Replayed on the iPhone traces, it still finds bf41176's
jump under a real fling.
…hey are

Rows' hosting views already inherit the list's environment, Zero client
and custom values alike (checked on macOS and the iOS 26.5 simulator), so
0ff3f12's explicit .environment(\.zero, zero) was redundant. It goes, and
so does ZeroVirtual's dependency on ZeroUI; the doc comment now says rows
inherit. The test stays, guarding the inheritance.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…lGeometryHost goes

VirtualList lays its rows out with LayoutHost since a018d98, and nothing
used ScrollGeometryHost. Its scenario tests (paging, taller rows, stick
to bottom, a far jump, flings, a fling to the top from a permalink) now
drive LayoutHost through a fake view that runs its layout passes, and
check every pass, not only where the list settles: a row the test holds
is never drawn moved, the core never scrolls under a fling away from the
start, and no gap stands above the first row at the top. A new case
checks LayoutHost takes leftover space at the start out at once. The
tests of ScrollGeometryHost's own reporting go with it.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
93af68b committed the renamed ScrollGeometryHost tests, not their port to
LayoutHost, so nothing built. This is the port it describes.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
VirtualListView is VirtualList for UIKit and AppKit apps, with rows that
are the app's own views. It takes the same ZeroVirtualizer and a row
closure, (item, reusing) -> UIView/NSView, which makes a row's view or
shows the row in one another row left. Views of rows that leave are kept
hidden in one pool. A row whose data changes is shown again in its own
view. Heights come from Auto Layout at the list's width
(systemLayoutSizeFitting; on macOS, fittingSize with a width constraint,
after laying out at that width so wrapping labels wrap there), and
rowHeightDidChange(_:) is for rows that change height on their own.

It's the same native scroll view as VirtualList's, exposed as scrollView
for insets and delegates, so the rows' frames, the content size and the
offset still change in one layout pass. It follows the virtualizer while
it's in a window.

AppleTests open it in a window and scroll to a row far below, and check
that a reused view shows its new row; each fails when its part is undone
(not attaching; reusing a view without showing the new row in it).

Underneath, RowsLayout no longer hosts SwiftUI itself: how a row's view is
made, shown again when its row changes, and measured is a RowViews
(HostedRowViews for VirtualList, unchanged in behavior; AppRowViews here),
and the rest of the layout is the same for both.
… too

With -native 1, TestHost's chat and issues lists are VirtualListViews of
UIKit or AppKit rows (NativeRowView: two labels and the probe's marker,
one accessibility element like the SwiftUI rows). NativeVirtualListUITests
and NativeFrameProbeUITests run the same tests on them, and CI runs each
in its own job.
ubuntu-latest moved to Ubuntu 26.04, and the cache, keyed by Playwright's
version alone, restored a WebKit built for 24.04 that can't load
libicudata.so.74 there: Public Test failed on main at b39e85c.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Git merged pnpm-lock.yaml without a conflict, but upstream's move to
esbuild 0.28.2 and vite 8.3.4 left the zero-swift-cli and
zero-conformance importers on the old versions, so a frozen install
failed. Those importers now take the new versions; nothing else in the
lockfile changes.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…buntu 24.04

Public JS runs on pull requests into main, so PR #1 was the first to check
zero-swift's CLI:
- syncpack: its esbuild follows the workspace's 0.28.2. It stays on
  TypeScript 6, as it uses the compiler API at runtime, which TypeScript 7
  doesn't have, and its @rocicorp/zero peer range is the app's, not the
  workspace's version; both are recorded in .syncpackrc.
- oxfmt: zbugs-screenshots.yml formatted.
Public Test moves to ubuntu-24.04: ubuntu-latest is now 26.04, which
Playwright 1.60 has no browsers for.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants