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 namedSwiftUI. 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.shbuilds the example for Wasm and copies it intoWeb/public/;snapshot-test.shruns the example headless under wasmtime and diffs againstExamples/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
SwiftUImodule, which is intentional: on Apple platforms the app uses the real thing.
Phase 2 notes#
- Keyed children.
ForEachgives its children identities. Both the mounted tree and the reconciler match keyed children by identity, so a row keeps its@Stateand its element id across inserts, removes and reorders, and a reorder becomes a singleinsert(move) op. - Navigation.
NavigationStackkeeps the pushed screens in a@Statearray inside its own view struct, so it survives re-renders like any state. The runtime wraps the root and each pushed view in anavscreenelement, liftsnavigationTitleinto the screen's props, supplies the push handler forNavigationLinkand the pop handler for the screen, and puts a matchingdismissinto the environment. - Environment. Values flow down during reconciliation.
@Environmentproperties 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
@Statevalues keyed by each view's path (position and type from the root) and restore them from theSB_STATEenvironment 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.
withAnimationrecords a transaction on the main actor; the render pass it causes stamps itscommitwith 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 ananimationTokenprop 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 customLayoutor aGeometryReaderanswering 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.
.sheetmounts the content as a second child of the presenting node while presented, so its state survives, but emits it as a root-levelsheetelement after the app's tree. Its handler flips the binding; the content's environment carries a matchingdismiss. - 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
ExecutorFactorySPI (the same route JavaScriptKit takes on Swift 6.4): jobs queue until the host callssb_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,tabItemandForEachids through the wrapper views, and emits onetabelement per option (all mounted, one selected) or the option labels on apicker. - 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
geometryevent 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.