SwiftBrowser docs
View as MarkdownEdit on GitHub

Architecture#

SwiftBrowser runs SwiftUI apps in the browser so iOS development does not require a Mac for day-to-day iteration. The invariant everything else protects: an app's source imports SwiftUI and compiles unchanged for both iOS and the browser.

Examples/Counter/Sources/*.swift      the app: plain SwiftUI, no SwiftBrowser imports
        │                                      │
        │ swift build --swift-sdk wasm          │ xcrun swiftc -typecheck (macOS CI)
        ▼                                      ▼
Sources/SwiftUI (shim module)            Apple's real SwiftUI
  view tree → element tree → ops
        │
        ▼  docs/ops-protocol.md
Web/ (TypeScript renderer)   or   headless JSON Lines (wasmtime, CI snapshots)

Pieces#

  • Sources/SwiftUI: a SwiftPM library target literally named SwiftUI. It implements an API-compatible subset of SwiftUI (see the compatibility matrix in the README), state storage, and a reconciler that diffs element trees into ops. It has no dependency on Foundation or JavaScriptKit, so it also builds natively on Linux for fast unit tests.
  • Examples/Counter: the first app. Its sources are compiled two ways: into a Wasm executable against the shim, and type-checked against Apple's SwiftUI on a macOS runner so the shim can never drift from the real API.
  • Web/: a Vite + TypeScript project. It instantiates the Wasm module with a WASI shim, applies ops to the DOM inside an iPhone-shaped frame styled with iOS design tokens, and forwards taps back to Swift.
  • scripts/: build-wasm.sh builds the example for Wasm and copies it into Web/public/; snapshot-test.sh runs the example headless under wasmtime and diffs against Examples/Counter/__snapshots__/.

Why this shape#

  • Emitting ops instead of touching the DOM keeps the Swift core testable without a browser and lets the renderer be swapped (a canvas renderer with a faithful SwiftUI layout pass is the Phase 2 option).
  • The package is only ever built for Wasm and Linux. Building it on macOS would collide with the system SwiftUI module, which is intentional: on Apple platforms the app uses the real thing.

Phase 2 notes#

  • Keyed children. ForEach gives its children identities. Both the mounted tree and the reconciler match keyed children by identity, so a row keeps its @State and its element id across inserts, removes and reorders, and a reorder becomes a single insert (move) op.
  • Navigation. NavigationStack keeps the pushed screens in a @State array inside its own view struct, so it survives re-renders like any state. The runtime wraps the root and each pushed view in a navscreen element, lifts navigationTitle into the screen's props, supplies the push handler for NavigationLink and the pop handler for the screen, and puts a matching dismiss into the environment.
  • Environment. Values flow down during reconciliation. @Environment properties are found by reflection, like @State, and resolved before the view's body runs. .environment(_:_:) and style modifiers write into the same context.
  • Observation. A render pass runs inside withObservationTracking; a change to any observed property marks the tree dirty, and the next event (or the end of the current one) re-renders. Mutations outside events, such as from timers or tasks, are not yet driven to the screen.
  • Hot reload. The runtime can snapshot primitive @State values keyed by each view's path (position and type from the root) and restore them from the SB_STATE environment variable at the next boot. Values whose path or type no longer matches are dropped silently.

Phase 3 notes#

  • Layout lives in the renderer. The Swift side still emits only the element tree. The renderer's layout engine (Web/src/layout) implements SwiftUI's propose-and-report protocol per element kind, measures text with the real font, and positions every element absolutely. Keeping layout in TypeScript avoids a synchronous Swift-to-JavaScript measurement bridge, keeps the module runnable under wasmtime with no custom imports, and lets the engine be unit-tested in Node with a deterministic text measurer.
  • Skipping unchanged views. A node re-runs its body when it is dirty (its own state or an observed value changed). When its parent re-runs it receives a new view value; if that value and the environment it receives are the same as last time, it is skipped with its subtree, like SwiftUI does for views whose stored properties are equal (docs/ops-protocol.md, "Phase 16"). Which of the runtime's protocols a view conforms to is worked out once per type (_ViewTraits), not cast for on every pass ("Phase 17").
  • Animation. withAnimation records a transaction on the main actor; the render pass it causes stamps its commit with the animation and the renderer animates every frame and style change in that pass. .animation(_:value:) is a styled wrapper whose mounted node remembers the last value and bumps an animationToken prop when it changes; only that subtree animates. Transitions are a style field read when the element is inserted or removed inside an animated commit. A pass that moves an element whose frame is still tweening (a custom Layout or a GeometryReader answering the animated pass a frame later) retargets the tween from where the element is drawn to its new frame, in the time the tween had left; it never leaves the old tween drawing a stale frame.
  • Sheets. .sheet mounts the content as a second child of the presenting node while presented, so its state survives, but emits it as a root-level sheet element after the app's tree. Its handler flips the binding; the content's environment carries a matching dismiss.
  • Dynamic Type. Fonts travel as text-style names and the renderer resolves them against Apple's Dynamic Type table for the current size, which arrives through the environment event like the color scheme.

Phase 4 notes#

  • Executor. Swift concurrency on Wasm has no event loop of its own. The shim installs a cooperative executor through the ExecutorFactory SPI (the same route JavaScriptKit takes on Swift 6.4): jobs queue until the host calls sb_run_jobs, and sleeps become timers against a clock the host sets. The browser passes real time; headless mode jumps a virtual clock from timer to timer, so timer-driven apps snapshot deterministically. Native test processes never install it, because the test runner drives the main actor itself.
  • Stable state storage. A long-lived task captures the view value it was started from. State boxes therefore share one persistent storage object across re-renders instead of copying values, so a closure captured before a re-render still writes to the live value.
  • Tabs and pickers. The runtime flattens the content into options, reading tag, tabItem and ForEach ids through the wrapper views, and emits one tab element per option (all mounted, one selected) or the option labels on a picker.
  • Value-based navigation. navigationDestination(for:) registers a builder on the enclosing stack's node while the root screen reconciles; path screens are placeholders resolved right after, so a value appended to the path binding becomes a screen in the same pass.
  • GeometryReader. The renderer reports the laid-out size through a geometry event only when it changes; the node stores it and the content re-renders with the real size, so the loop converges in one extra pass.

Foundation#

The shim imports Foundation and apps link the wasm SDK's swift-corelibs Foundation, with ICU's locale data trimmed to the app's languages by the CLI (see packages/cli/src/icu.ts and docs/ops-protocol.md, "Phase 11"). What corelibs lacks on WASI lives in Sources/FoundationBridge: Timer and RunLoop, a UserDefaults the browser persists, a Bundle over the staged resources, URLSession and AsyncImage over the page's fetch, and the viewer's Locale. An app file says import Foundation and nothing else, so the same file compiles against Apple's SDKs. Earlier phases had a lean mode on FoundationEssentials plus a small formatting layer; Phase 14 removed it (README, "Phase 14"). Phase 15 trimmed further (docs/ops-protocol.md, "Phase 15", with a breakdown of where the bytes go and why dead-code elimination keeps them); a small app is 17.4 MB, 4.5 MB with brotli. The sizes below are from the lean era and remain a fair picture of what each piece costs before trimming.

Build (release, -Osize, stripped) Raw gzip
Settings example (no Foundation) 9.6 MB 2.6 MB
FoundationEssentials probe 12.7 MB 3.7 MB
Full Foundation probe 53.8 MB 19.1 MB

SwiftData#

Sources/SwiftData is SwiftData over the page's IndexedDB (docs/ops-protocol.md, "Phase 18"). The store is in Swift memory for the run, handed in at launch (SB_SWIFTDATA) and written back save by save (swiftdata ops), because SwiftData's API is synchronous and IndexedDB's is not: fetching from memory keeps context.fetch and @Query synchronous, as on iOS. @Model and #Predicate are compiler plugins (Sources/SwiftDataMacros, swift-syntax), so only apps that import SwiftData pay for them; the CLI links the module when a source imports it.

Files#

An app's home directory (/data, HOME) is an in-memory WASI directory the page fills from IndexedDB before the module starts and writes back after any event or executor run that wrote to a file (docs/ops-protocol.md, "Phase 19"). Nothing in the protocol changes: the page notices writes by watching the WASI calls that can change a file, then diffs the tree against what it last saved.

Bitmaps#

UIImage, CGImage and Core Image's CIImage hold recipes, never pixels (docs/ops-protocol.md, "Bitmaps and Core Image"): an image file's bytes (or a generator's pixel buffer, such as a QR code's modules) plus the filter steps to run. Swift reads sizes from file headers, so UIImage(data:) stays synchronous as on iOS; the page decodes the file with the browser and runs the steps in TypeScript (Web/src/imageOps.ts) at the size the image is shown. No image codec is compiled into the module. UIImage and CGImage are in the shim because SwiftUI re-exports them on iOS; Sources/CoreImage is linked only into apps that import it.