Repository navigation
Commit 799aafa
feat: Swift client for Zero (iOS and macOS) (#1)
* docs(swift): add design for native Swift client
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>
* build(swift): scaffold Swift package, conformance package, and CI
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>
* ci(swift): run Swift workflows on the swift-client branch
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* feat(swift): port JSON, hashing, AST normalization, and value primitives
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>
* feat(swift): port IVM operators, pipeline builder, views, and query builder
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>
* feat(swift): port the zero-protocol wire messages
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>
* fix(conformance): generate vectors on the pinned Node, guard JSON.parse
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>
* feat(swift): walking skeleton against a real zero-cache
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>
* feat(swift): typed key-path query builder and zero-swift codegen
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>
* test(conformance): client scenario vectors from the JS zero-client
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>
* feat(swift): client engine on memory storage (M4)
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>
* test(conformance): persistence scenarios with a restart step
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>
* feat(swift): SQLite persistence and the client lifecycle (M5)
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>
* test(conformance): visibility scenarios with a fake document
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>
* feat(swift): app lifecycle, live queries, and the Apple layer (M6)
- 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>
* fix(swift): build the SwiftUI and AppKit helpers on macOS
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>
* test(swift): the whole client against zero-cache end to end
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>
* fix(swift): compile the Apple tests on macOS
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* feat(swift): drift harness for Swift ports of TS queries and mutators
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>
* feat(zbugs): zbugs' schema, queries, and mutators in Swift (M7)
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>
* feat(zero-swift): flag mutators that read the clock or make up IDs
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>
* feat(swift): read nulls the schema rules out, and share the NIO transport
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>
* feat(zbugs): native app login redirect and a dev token script
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>
* feat(zbugs-swift): the zbugs app for iOS and macOS
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>
* test(zbugs-swift): the Swift port against zbugs' own backend
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>
* fix(zbugs-swift): read the project name off the main actor
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): name the app's module apart from the ZBugs library
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* perf(swift): what the benchmarks found
- 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>
* feat(swift): benchmarks, next to the JS ones
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>
* refactor(swift): keep the engine out of the public API
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>
* docs(swift): DocC catalogs, a README, and the JS parity matrix
A catalog per library product: Zero (getting started, and what each JS
option, method, and React hook is called in Swift), ZeroUI, and
ZeroTesting. swift/README.md is the mirror's front page, with the
compatibility table.
scripts/docs.sh builds the catalogs with warnings as errors; CI runs it
for Zero and ZeroTesting on Linux, and for all three on macOS (ZeroUI's
symbols exist only there). Oxfmt skips the catalogs, since it turns
DocC's double-backtick symbol links into code voice, and the recorded
drift vectors, like the conformance vectors.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* build(zero-swift): bundle the CLI for npm
The CLI ran from its TypeScript sources and imported other workspace
packages' sources by relative path, so it couldn't be published.
`pnpm --filter zero-swift build` now bundles bin.ts with esbuild into
dist/, leaving out typescript and @rocicorp/zero. publishConfig points
the bin at the bundle. bin.ts is the entry point because cli.ts's
own check (its URL against argv[1]) fails behind npm's .bin symlink.
@rocicorp/zero is a peer dependency (>=1.11.0, the first release with
sync protocol 53): drift record must run the app's own copy. In the
workspace, a workspace:* devDependency satisfies it.
Checked by packing the CLI and running codegen, codegen --check, and
drift record in a scratch app against @rocicorp/zero 1.11.0 from npm.
The package stays private, at 0.1.0, until the publish is approved.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* test(zbugs-swift): screenshots of each screen against zbugs' backend
A UI test (App/UITests) drives the app through the issue list, an issue,
the new issue sheet, the filters, and the UIKit list, keeping a
screenshot of each. A new workflow runs it in the iOS Simulator against
server.ts on the macOS runner and uploads them.
The app reads ZBUGS_TOKEN, ZBUGS_CACHE_URL, and ZBUGS_API_URL from its
environment, so the test logs in without the login sheet.
On iPhone, the split view now starts at the issue list rather than the
filters, which had no way forward; the filters get an Issues button,
and a selection from the UIKit list shows the issue.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): make the app model once, in the App
RootView made its AppModel in a @State initializer, which SwiftUI runs
each time it re-creates the view; each throwaway model started a client.
In the simulator against zbugs' backend that came to about 830 clients
in three minutes, each closing before it synced, so the list never
loaded. The App makes the model now and passes it in.
ZeroUI's docs say where to make a client.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): let zero-cache forward to either loopback name
server.ts allowed zero-cache to forward queries and mutations only to
http://127.0.0.1:<port>, but the app named the API server
http://localhost:5173 by default, so zero-cache refused to transform its
queries (TransformFailed) and the list never loaded. server.ts now
allows both names on a loopback host, and the app defaults to 127.0.0.1,
the address server.ts prints.
The screenshots workflow also keeps the client's log.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): issue numbers without separators, a badge that fits, and the picker the test taps
The navigation title formatted the issue number for the locale ("#3,000").
On an iPhone the connection badge shows only its dot. The UI test finds the
filters' List picker by its label, which is its title and its value.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* test(zbugs-swift): screenshots on iPad and macOS too
The UI test runs on an iPhone and an iPad simulator and on macOS, where the
native list is AppKit. It finds issues by accessibility identifier, not as
the first cell, which on an iPad or a Mac could be a filter. When a screen
doesn't appear it also keeps the accessibility tree.
The split view starts with all three columns, so an iPad or a Mac shows the
filters, and the window has a default size. A SwiftUI row reads as one
element.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* feat(zbugs-swift): run the app on a device against a backend on the Mac
With ZBUGS_HOST=0.0.0.0, server.ts serves the network and zero-cache
accepts the API server at any of the machine's addresses. The UI test
passes ZBUGS_CACHE_URL and ZBUGS_API_URL on to the app, and the app says
why it asks for the local network.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): room for the filters on macOS, and the picker the test clicks there
On macOS the sidebar was too narrow for the pickers' values, and the list
column for its titles. The List pop-up button has no label of its own, so
the UI test finds it by its value.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): sheets that fit an iPhone, and an AppKit list like the SwiftUI one
From running the app on a Mac and an iPhone:
- The sheets' minimum size, for macOS, made them wider than an iPhone,
clipping Create. On macOS the issue forms are grouped, like the others,
not labels in a column beside the fields.
- The AppKit list truncates its titles instead of clipping them, shows the
status icon, and highlights the issue the detail shows, as the UIKit list
now does on an iPad.
- Tapping the issue the detail shows opens it again from the UIKit list,
which doesn't change the selection.
- A row's meta line doesn't wrap before its labels.
- On a device, the UI test allows local network access.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): keep a row's labels whole in a narrow list
On a Mac the list column is narrow enough that the labels were squeezed to
a letter or two. They keep their size, and go below the subtitle when they
don't fit beside it.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): highlight the open issue in the UIKit list on an iPad
The list checked its own size class, which a split view's column reports
as compact on an iPad too. The root view, which sees the window's, decides.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): give the Mac's list column its width
`searchable` drops a column width set before it, so the list was 200pt
whatever the minimum. Set after it, the list opens 340pt wide.
Found by running the app on a Mac.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): keep the UIKit list's highlight when it appears
UITableViewController clears its table's selection as the view appears,
which on an iPad is after the root view set it.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* perf(zero-swift): rows keyed by column index
A row was a [String: JSONValue], so every column read hashed the name:
comparing rows by key and reading rows as structs mostly hashed. `Row` is
now a struct that reads and writes like that dictionary but keeps its values
in an array, in the order of a layout the rows of a table share.
A source puts the rows it gets in its table's layout. Its comparators read
the columns they sort by by index, and the generated init(entry:) passes
each column's position in the table, so decoding reads it there. A row in
another layout falls back to its names. Rows on the wire and in storage stay
dictionaries (`RowRecord`), as does a query's start bound.
100,000 rows of 10 columns, on this machine:
materialize 25.0 ms -> 15.3 ms
materialize as structs 85.9 ms -> 28.3 ms
The table mutators also take a [String: JSONValue], so code that built one
for them still compiles.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* perf(zero-swift): a row in one allocation, shared with the main branch
A row was a layout and an array of values: two references to retain on
every copy, where the dictionary it replaced had one. Its layout and values
now share one buffer.
A mutation's fork and the main branch each made their own layout for a
table they hadn't read yet, so the main branch copied every row the
mutation wrote. Layouts now come from the branch and its forks share them,
and the main branch takes the rows the transaction wrote.
100,000 rows of 10 columns, against the commit before rows keyed by
column index:
materialize 24.6 ms -> 11.5 ms
materialize as structs 91.2 ms -> 29.7 ms
memory, with a view 98 MB -> 128 MB (JS: 164 MB)
The memory is a source's rows, which no longer share their storage with
the local store's dictionaries.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* perf(zero-swift): B-tree lookups without retaining each node
get() walked the tree with strong references, retaining and releasing each
node it passed. The tree holds its nodes for the walk, so it no longer
does: a lookup in a tree of 1000 went from about 64 ns to 43 ns.
BENCHMARKS.md has the numbers after rows keyed by column index.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* docs(zero-swift): say Linux needs a socket of your own
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zbugs-swift): the list's large title on an iPhone when it has rows at launch
A compact split view that starts at the content column lays out the list
before its navigation bar is set up. A list that already has rows then (from
the local store, as when the app opens offline) keeps its large title
collapsed. Start at the filters and move to the list as the view appears.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db
Co-authored-by: Amp <amp@ampcode.com>
* test(zbugs-swift): screenshots offline too, from what the app stored
After the screenshots against the backend, the workflow stops it and runs
each platform again. The issue list can then only come from the app's local
store, so a launch that doesn't read it fails the job.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db
Co-authored-by: Amp <amp@ampcode.com>
* feat(zbugs-swift): a backend that keeps its database across restarts
server.ts recreates the database on every start and signs tokens with new
keys, so each restart sends every app that synced back to an empty store and
logs it out. With ZBUGS_KEEP=1 it keeps the database (where zero-cache keeps
the client groups), the replica, and the keys in ZBUGS_DIR, and seeds only a
database that doesn't exist yet.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db
Co-authored-by: Amp <amp@ampcode.com>
* docs(zbugs-swift): running the app on a device
The token and servers an app gets in its environment aren't saved, so on a
device opened from the home screen it goes back to the saved ones: set
Servers… and log in once.
Amp-Thread-ID: https://ampcode.com/threads/T-01a1557f-dff8-761e-ad04-7fc6c1bd83db
Co-authored-by: Amp <amp@ampcode.com>
* fix(zero-swift): ZeroTesting no longer links Testing.framework
Drift.expectNoDrift imported Testing, so everything that linked
ZeroTesting linked Testing.framework, which on Apple platforms only test
bundles find: `swift run ZeroBenchmarks` died at launch on macOS. Tests now
expect `Drift.failures` to be empty, which lists each difference just the
same.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* perf(zero-swift): zero.bench checks its sum by column index
The check summed column `a` by name, on the main actor, which on macOS
checks its executor for every row: about 45% of "All 1000 rows" there.
JS reads `row.a` off the object's shape; the Swift check now reads `a` by
index, off the main actor. On Linux, All 1000 rows went from 197 to 122 µs.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* docs(zero-swift): benchmarks on the iPhone and an M2 Max first
The results were from one Linux Xeon, where JS is slowest, and overstated
Swift's lead. They now lead with an iPhone 15 Pro, where JS in WebKit is
ahead on the read path and Swift on optimistic updates, memory, hashing,
and B-tree scans, then an M2 Max next to Linux. The shared benchmarks'
command runs Node only, without headless Chromium beside it.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* feat(zero-swift): ZeroVirtual, lists that page their rows in
The paging core of @rocicorp/zero-virtual for Swift, without its views:
a window of at most three queries (a page, or a permalink's row and a
page either side of it) that moves as the list scrolls, keeping what's on
screen in place as rows are added or removed above it or change height.
It has permalinks, scrollToItem with alignment, the first and last
visible rows, scroll state to save and restore, settling, and following
the bottom while the list is parked there.
ZeroVirtualizer runs the core's queries on a Zero client. The page and
row queries take the list context, so a new filter queries the new list.
Scroll view adapters come next.
zero-virtual's behavior tests are ported by hand with their names;
Tests/ZeroVirtualTests/UPSTREAM.md lists each upstream case at f89c468 as
ported, adapted, or not applicable.
Restores are a call, restore(_:), rather than an option the core
compares: a state is a value, so restoring the same one twice looked like
no change.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* feat(zero-swift): VirtualList, ZeroVirtual's SwiftUI scroll view
VirtualList draws a ZeroVirtualizer's window of rows in a ScrollView,
between spacers, in a plain VStack: the window is already what to draw,
and each row must be measured. ScrollGeometryHost turns SwiftUI's after-
the-fact geometry (row frames, the scroll geometry) into the core's
VirtualHost, and tells the core of an update once every row is measured
and the content reaches them. The core's own scrolls go through a
ScrollPosition and are reported back as they land.
LoopbackServer (ZeroTesting) is a server in the app's process: it sends
its rows, confirms every query, and syncs rows added later. The new test
app, swift/TestHost, runs a chat that sticks to the bottom (rows of many
heights) and a long list on it, with UI tests in the screenshots
workflow.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* perf(zero-swift): ContiguousArray B-trees, struct scans, appending hydration
Five changes, measured one at a time on an iPhone 15 Pro and an M2 Max:
- B-tree nodes hold ContiguousArrays: Darwin's Array checks for NSArray
bridging on every read. Scans 3.6-4.5x faster, lookups about 2x.
- Row reads its layout and slot count through the header pointer, not
the stored class property, which checked exclusivity each time.
- MemorySource's scans and overlays are struct iterators, not closures
over captured variables behind AnyIterator.
- Hydration appends at the end of the list rather than inserting, which
copied each entry.
- Column predicates look their column's index up once per row layout.
On the iPhone, "all 1000 rows" went from 95.6 to 41.8 us and
materializing 2.0-2.5x faster: Swift is now ahead of JS in WebKit on the
read path. BENCHMARKS.md has the new numbers.
Co-authored-by: tmm <tmm@tmm.dev>
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
* fix(zero-swift): VirtualList re-renders every snapshot
The core waits for afterUpdate after each snapshot, even one with the
same rows, as after scrollToItem. VirtualList only re-measured when the
row keys changed, so a scroll to a row far away never landed. The
virtualizer now tells the adapter of every snapshot (onSnapshot).
An AppleTests case checks a scroll far below in a real window, and the
test app keeps its lists off the safe area so the UI tests measure rows
against the viewport.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zero-swift): VirtualList updates when content rounds below its rows
ScrollGeometryHost held afterUpdate until the scroll view's content
reached the last row. SwiftUI rounds the content size to pixels while
rows of text end on fractions, so with no space after the rows the
update could wait forever: scrollToItem never landed and paging never
started (the chat opened at its top on the iPhone). It allows a point.
The test app's rows are one accessibility element with the padded row's
frame, which the UI tests measure, and the AppleTests case waits without
a nested run loop, which didn't run the main queue on macOS.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* fix(zero-swift): a scroll that lands at the bottom re-arms stick-to-bottom
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>
* test(zero-swift): the loopback paging test waits for the last page's 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>
* test(zero-swift): find the scrolled-to row by its place in the window
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>
* test(zero-swift): virtual list UI tests read rows from one snapshot
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>
* fix(zero-swift): VirtualList no longer takes the scroll view's insets 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>
* fix(zero-swift): pages loading mid-fling no longer stop it
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>
* fix(zero-swift): VirtualList keeps row frames through a new space before
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>
* fix(zero-swift): flings through a list opened mid-way never scroll under
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>
* test(zero-swift): a cheaper fling test, and room for VirtualListTests 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>
* test(zero-swift): the chat UI test swipes up to 70 times to reach its 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>
* fix(zero-swift): the give-back at the start scrolls with the space it 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>
* revert(zero-swift): the give-back at the start scrolls with the space 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>
* test(zero-swift): a frame probe checks every display frame of the virtual 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.
* fix(zero-swift): paging waits for the space that keeps rows in place
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.
* fix(zero-swift): VirtualList lays its rows out in a native scroll view
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).
* test(zero-swift): the frame probe counts dropped frames and opens the 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.
* fix(zero-swift): no gap stands above the first row after a fling to the 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.
* fix(zero-swift): rows on macOS stay on the content's pixels as it scrolls
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.
* test(zero-swift): the frame probe waits for a slow jump to move, and 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.
* ci(zero-swift): UI tests boot their simulator before launching the app
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>
* fix(zero-swift): VirtualList rows read the list's Zero client
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>
* test(zero-swift): the frame probe's momentum check allows a simulator'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.
* fix(zero-swift): VirtualList rows inherit the list's environment as they 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>
* test(zero-swift): the core's scrolling tests run on LayoutHost; ScrollGeometryHost 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>
* test(zero-swift): the LayoutHost tests themselves
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>
* feat(zero-swift): VirtualListView, the virtual list for UIKit and AppKit
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.
* test(zero-swift): the UI tests and frame probe run on VirtualListView 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.
* ci: key the Playwright cache by the OS release
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>
* ci: the Swift tooling passes Public Checks, and Public Test runs on Ubuntu 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>
* feat(zero-swift): virtual lists get a header, a footer, and a selection
VirtualList and VirtualListView lay out a header and a footer in the same
pass as the rows: they're part of the content, before the space before the
rows and after the space after them, so scrollToItem and the anchoring
account for them.
A selection of one row by key (a Binding for VirtualList; selection and
onSelect for VirtualListView): a tap selects a row, even the selected one,
and while the list has focus the arrow keys and j and k move the selection
and scroll it into view. The list highlights the selected row in the
platform's colors, passes isSelected to the row, and marks it selected for
accessibility.
* test(zero-swift): the Issues screen has a header, a footer, and a selection
The UI tests check the header pushing the rows at the top and leaving them
in place above the screen, the footer after the last row, and selecting by
a tap and (on macOS) by the arrow keys and j and k. The frame probe's
Issues script grows the header at the top and above the screen and, on
macOS, moves the selection by the arrow keys past the screen's bottom.
* fix(zero-swift): a list leaving doesn't detach the list that took its virtualizer over
Switching between two lists on one virtualizer (zbugs' SwiftUI and native
issue lists) can attach the new list before the old one leaves; the old
one then detached the virtualizer from the new list.
* feat(zbugs-swift): the issue list and an issue's comments page in as they scroll
The issue list is a VirtualList of issueListV2 pages, set up as zbugs' list
page sets up useZeroVirtualizer, in place of its first 200 issues; the UIKit
and AppKit list is a VirtualListView in place of the DiffableAdapter table.
Both use the virtual list's selection for the split view's highlight, and
open an issue on a tap, even the selected one.
An issue's page is one VirtualList: the header and the description above
its comments, which page in as zbugs' comments do, and the composer below.
The selected issue and the list's scroll state are kept in SceneStorage: a
relaunch opens the list at the issue, as the web app's list permalink does,
or else where it was.
A UI test scrolls the list of all issues past 200 of them.
* docs(zero-swift): VirtualListView's header, footer, and selection, by example
* fix(zero-swift): a VirtualList header or footer that goes lets go of its hosting controller
* fix(zbugs-swift): the UIKit issue cell keeps its icon beside a wrapping title, and a new filter opens at its top
The cell's nested stack views squeezed the icon out beside a long title,
and measured some titles at one line: plain constraints instead. A filter
change cleared the selected issue's permalink, so the new list opened at
its top, not at an issue it may not have.
* fix(zero-swift): a list opened at a permalink or where it was shows its rows offline
The list held off drawing its rows until it had landed at its target, and
it lands once the rows' queries complete. Offline, those queries never
complete, so the list stayed blank. It now draws once the viewport is
placed at the target, whether or not the queries have completed.
The offline zbugs screenshot runs skip the paging test, since the list
doesn't page past its first page offline.
* docs(zbugs): why the Swift app's login redirect can carry the token
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* ci(zero-swift): the Swift release also publishes the zero-swift CLI to npm
swift-mirror.yml becomes swift-release.yml. After the mirror job, it packs
packages/zero-swift-cli without credentials and publishes the tarball with
npm trusted publishing, in the swift-release environment. The npm jobs run
once the ZERO_SWIFT_NPM_PUBLISH variable is 'true', and the package version
must match swift/VERSION. The package is no longer private, and the mirror
is public.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* test(zero-swift): the frame probe lets a stalled frame scroll past every row
When no row is on screen in both frames, the probe flagged a jump. A
stalled main thread (a slow CI simulator's) lets a fling run on by a
screenful or more between two frames, and UIScrollView catches up on the
frame after it, so neither frame shares a row with the one before. The
probe now compares the rows laid out in both frames by their content
positions: a scroll leaves them where they were, less what the list
compensated in that pass; a jump moves them. Up to a quarter of the
offset's change, or 50 pt, counts as a scroll. The flag reports dt, the
callback latency and the offset.
* fix(zero-swift): rows are selected by keys on macOS only
On iOS the arrow keys and j and k needed a hardware keyboard, and the
list took first responder on a tap only for them. The iOS list now has
no key commands and doesn't take first responder; a tap still selects a
row, highlights it, and calls onSelect, and the selection binding and
the accessibility traits are as they were. macOS keeps its keys.
* build(zero-swift): the CLI's package names its repository, which npm provenance checks
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* ci(zero-swift): Changesets version the Swift client, and releases publish to npm by default
A changeset for zero-swift versions the CLI and the Swift package together:
pnpm changeset:version writes the CLI's version to swift/VERSION. On main,
swift-release.yml keeps a version PR open. With no changesets pending, it
releases the version when its mirror tag or npm package is missing, after
approval in swift-release. Each step skips what's already released, so a
failed run can be rerun. The ZERO_SWIFT_NPM_PUBLISH and mirror-token gates
are gone; a missing token or trusted publisher now fails the job.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
* chore(zero-swift): keep the fork's root package.json, lockfile, and .prettierignore as upstream has them
.prettierignore goes back to upstream's: appending to it had joined its last
line to ours. Changesets runs through pnpm dlx, pinned at 3.0.3, from
zero-swift's version-packages script, so it's no longer a root dependency.
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
---------
Co-authored-by: Amp <amp@ampcode.com>1 parent b39e85c commit 799aafa
276 files changed
Lines changed: 112229 additions & 7 deletions
File tree
- .agents
- .changeset
- .github
- actions/playwright-install
- workflows
- apps
- zbugs-swift
- App
- UITests
- Sources
- ZBugsUI
- ZBugs
- Tests/ZBugsTests
- server
- zbugs
- api
- scripts
- packages
- zero-conformance
- src
- bench
- client
- scenarios
- e2e
- ivm
- primitives
- protocol
- vectors
- zero-swift-cli
- src
- test
- swift
- Sources
- CSQLite
- ZeroBenchmarks
- ZeroNIOTesting
- ZeroTesting
- ZeroTesting.docc
- ZeroUI
- ZeroUI.docc
- ZeroVirtual
- Zero
- AST
- Apple
- Builder
- Client
- Hash
- IVM
- JSON
- Protocol
- Query
- Schema
- Storage
- Util
- Zero.docc
- TestHost
- App
- Generated
- UITests
- Tests
- AppleTests
- ConformanceTests
- Generated
- E2ETests
- Generated
- ZeroTests
- Generated
- ZeroVirtualTests
- Generated
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
| 10 | + | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| |||
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
20 | 41 | | |
21 | 42 | | |
22 | 43 | | |
| |||
40 | 61 | | |
41 | 62 | | |
42 | 63 | | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
43 | 72 | | |
44 | 73 | | |
45 | 74 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
10 | 14 | | |
11 | 15 | | |
12 | 16 | | |
13 | 17 | | |
14 | 18 | | |
15 | 19 | | |
16 | | - | |
| 20 | + | |
17 | 21 | | |
18 | 22 | | |
19 | 23 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
82 | | - | |
83 | | - | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
84 | 85 | | |
85 | 86 | | |
86 | 87 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
0 commit comments