Repository navigation
Conversation
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>
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>
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>
Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev>
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Swift client for Zero on iOS and macOS, with parity to the JS client, checked against conformance vectors generated from the JS implementation.
What's here
swift/: theZeroSwift package. It has the client (connection, mutations, queries and IVM),ZeroUI(@ZeroQueryfor SwiftUI),ZeroVirtual(virtual lists:VirtualListfor SwiftUI andVirtualListViewfor UIKit and AppKit, a counterpart of@rocicorp/zero-virtual),ZeroTesting, andTestHost, 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
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), andswift-mirror.yml(mirrorsswift/towevm/zero-swift, which doesn't exist yet)..github/actions/playwright-install: the browser cache is now keyed by the OS release too.ubuntu-latestmoved to Ubuntu 26.04, and a WebKit cached on 24.04 failed Public Test onmainat b39e85c.apps/zbugs/api/index.ts: the GitHub login callback now redirects tozbugs-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. Thepackage.jsonandtsconfig.scripts.jsonchanges register it..agents/setupfor orbs, andpnpm-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.