History#
How SwiftBrowser got here, phase by phase, oldest first. What works today is summarized in Compatibility; how to use it is in Getting started.
Phases 1 to 4#
Phase 1 proved the pipeline, Phase 2 added navigation, lists, input and observation, Phase 3 added the layout engine, animation, sheets and Dynamic Type. Phase 4 adds:
TabViewwithtabItemandtag, drawn with an iOS tab bar; every tab stays mounted so its state survives switching.Picker(segmented and menu styles) andForm, plus list styles.- Value-based navigation:
NavigationStack(path:),NavigationPath,navigationDestination(for:)andNavigationLink(value:), with programmatic pushes and pops through the path binding. - Swift concurrency on Wasm: a host-driven executor so
Task,.task,Task.sleepandContinuousClockrun and their state changes reach the screen. Headless snapshots drive it with a virtual clock. GeometryReaderwith the laid-out size reported back from the renderer.- A fourth example,
Examples/Settings, exercising all of it.
Phase 5: a real app (Food Truck, step 1)#
Examples/FoodTruck: Apple's Food Truck sample, vendored and trimmed (its README lists every change). The donut model, orders, cities and the Truck / Orders / Donuts screens run; the store, weather, maps, charts and widgets do not.- Foundation on Wasm: apps link
FoundationEssentials(Date, Calendar, Decimal) plusSources/FoundationLite, which suppliesformatted()and friends without Foundation's 40 MB of ICU data (docs/architecture.md). - Asset catalogs:
Image("name")andColor("name")read*.xcassetsunder the example, served and bundled byWeb/plugins/asset-catalogs.ts(one PNG per image set, light/dark color sets, custom symbol sets). - Combine-style observation:
ObservableObject,@Published,@StateObject,@ObservedObject,@EnvironmentObject. .aspectRatio/.scaledToFit/.scaledToFill,.id,.compositingGroup,Image.resizable()for catalog images,.offsettransitions,Animation.spring(response:dampingFraction:).
Phase 6: Food Truck, step 2#
- The app-level screens of Apple's sample run from their own sources: the sidebar, Truck cards (brand header, orders, donuts, social feed), the donut gallery grid and editor, orders with sections and badges, the completion sheet with the donut box, the social feed with flow-layout tags.
- Custom
Layout: the renderer measures the subviews (min, ideal, max) and sends the proposal to Swift, which runssizeThatFits/placeSubviewsand returns frames plus its own min / ideal / max, so nested layouts measure each other correctly; one frame of latency, likeGeometryReader. Grid/GridRow,LazyVGrid,Gauge,Menu,.toolbar,.searchable,.onChange,TimelineView,LabelStyle,.overlay/.backgroundwith views and shapes,ShapeStyle(hierarchical levels, linear and radial gradients,Color.gradient, materials),.offset/.rotationEffect/.scaleEffect/.shadow/.clipped/.position,.badge,.listRowInsets,.tint,.imageScale,.onTapGesture,.sheet(item:), asymmetric transitions,@ScaledMetric,LocalizedStringKey.
Phase 7: Food Truck, step 3#
- Swift Charts: a
Chartsmodule withChart,BarMark,LineMark,AreaMark,RectangleMark,PointMark,RuleMark, axes (AxisMarks, grid lines, ticks, text or view labels), legends, scales and date bins. Swift maps data to unit coordinates; the renderer measures labels, lays out the plot and draws the marks as SVG. Sales History, Top 5 and the Truck weather card run from Apple's sources. NavigationSplitViewwithList(selection:): a stack on phones, two columns from 700 pt (the renderer reportshorizontalSizeClass); tablets now fill the viewport instead of showing the phone bezel.ViewThatFits,.hidden(), gradient.mask, shadowed shape styles, two-toneforegroundStyle, and the City panel (parking showcase on a drawn map, weather and parking cards).
Phase 8: interaction#
- Gestures:
DragGesture,LongPressGesture,TapGesturewith.gesture/.highPriorityGesture/.simultaneousGesture,.onLongPressGesture,allowsHitTesting; the renderer tracks pointers and streamsdragevents once per frame. - Lists:
swipeActions(tints, destructive, full swipe),contextMenu,onDelete/onMovewith swipe to delete,EditButtonandeditModewith the minus / selection circles,List(selection:)on tagged rows for single and multi-selection,refreshablepull to refresh, swipe-back navigation. - Presentation:
alert(iOS alert card),confirmationDialog(action sheet),fullScreenCover. - Controls:
Stepper,Slider,DatePicker(native pickers behind compact capsules),TextEditor,Text(_:format:)andTextField(_:value:format:)with FoundationLite's.currency/.number. - Effects:
rotation3DEffect,blur, realignoresSafeArea,repeatCount/repeatForeveranimations,Binding.animation,ViewModifier,scenePhase, the accessibility environment. - The driver is
Examples/HackingWithSwift: five of Paul Hudson's 100 Days of SwiftUI projects (Guess the Flag, BetterRest, Animations, iExpense, Hot Prospects, Flashzilla) running from their sources.
Phase 9: incremental rendering#
- Swift: a state change re-runs only the bodies that depend on it. Each
mounted view tracks its own
@State,@Observablereads and observed objects; clean subtrees are skipped whole and their cached elements reused, and the reconciler skips unchanged subtrees by identity instead of diffing them. - Web: the layout pass lives across commits. Only the subtrees an op touched (and their ancestors) are measured and placed again; the DOM pass visits only what changed.
- On Food Truck's 20 fps header: Swift ~24 ms → ~2 ms per tick, renderer ~5 ms → ~1.5 ms per commit, main thread ~36% → ~6% busy. Protocol unchanged.
Phase 10: bring your own app#
npx @swiftbrowser/cli dev ~/Projects/MyAppruns an iOS app from its Xcode project (project.pbxproj, Xcode 16 synchronized folders included) or a folder of sources:doctorinstalls the toolchain pieces,checkreports the imports and compiles against the shim in seconds,devserves it with rebuild on save,buildwrites a static site. Seepackages/cli/README.md.- The project is never modified: a staging SwiftPM package under
<project>/.swiftbrowser/holds a copy of the sources adjusted for the shim (import SwiftUIgains the Foundation modules Apple's SwiftUI re-exports,import Foundationbecomes FoundationEssentials + FoundationLite,#Previewblocks are blanked,SWIFTBROWSERis defined), with line numbers intact. - Foundation measured: the wasm SDK's full Foundation adds about 42 MB (36 MB
of ICU data) as soon as anything from it is used; FoundationEssentials adds
4 MB. Lean mode is the default, and FoundationLite grows with what apps need
(
UserDefaults,Timer.publish+onReceive, format styles,IndexSet,CharacterSet). - Full Foundation made viable:
--foundation fulltrims ICU's 34 MB data package to the project's"locales"(English by default, 5.6 MB) and links it in the SDK's place through a data-only target of the staged package, so an app that formats dates and currency with the real Foundation is 19 MB (6.6 MB gzipped) instead of 47 MB (18.5 MB). - npm packages:
@swiftbrowser/cli,@swiftbrowser/web(the renderer and dev server, now with an app registry),@swiftbrowser/xcodeproj(a dependency-free Xcode project reader) and@swiftbrowser/swift(the Swift package, so the CLI builds against the shim version it ships with). - Acceptance:
checkover all nineteen Hacking with SwiftUI projects, run from their Xcode projects unchanged. Five compile today (Guess the Flag, Views and Modifiers, Animations, iExpense, Flashzilla); the rest stop at a named gap: unsupported frameworks (SwiftData, Core ML, Core Image, PhotosUI, StoreKit) or shim API still missing (@FocusState,Path/InsettableShape,Bundle,onSubmit,scrollTargetBehavior,visualEffect,accessibilityInputLabels). CI runs the CLI on six of them.
Phase 11: the real Foundation by default#
- The CLI now builds against the wasm SDK's own Foundation (swift-corelibs)
unless asked for
--foundation lean:DateFormatter,NumberFormatter,NSString, locale-awareformatted(),Measurement,NSRegularExpressionandUserDefaultscompile as on iOS. With ICU's data trimmed to English (4.7 MB) a small app is 18 MB, 4.6 MB over the wire with brotli; lean stays for apps that must be small (4.5 MB, 1.2 MB brotli). FoundationBridge, a new module of the Swift package, is the shim's glue to the real Foundation:IndexSetforonDelete/onMove,DateforDatePicker, Foundation'sFormatStyles inText(_:format:)andTextField(_:value:format:), and theTimer+RunLoopthat corelibs Foundation does not have on WASI (Timer.publish+onReceiveincluded). The shim itself still never imports Foundation. The names both declare (CGFloat,CGPoint,CGSize,CGRect,Timer,RunLoop) are settled by typealiases in a file the CLI generates into the app.FoundationLiteis the lean mode's module; itsTimerand the bridge's share one executor-driven core in the shim (_IntervalPublisher,_ExecutorTimer), and a scheduled timer now lives until it fires, as Foundation's does.- CI builds the six examples and two Hacking with SwiftUI projects in both modes and runs them headless; the lean builds keep the op-stream snapshots.
- The app follows the viewer: the browser's time zone, and its language when
the module ships that language's data (else the first shipped one), with
?locale=to try another. swift-foundation hard-codesen_001on WASI, so the CLI redirects that function to FoundationBridge at link time. UserDefaultspersists: the module sends its defaults as adefaultsop, the page keeps them in localStorage per app and hands them back at launch.Bundle.mainworks: the target's Copy Bundle Resources are served next to the module and mounted at/bundlein the WASI filesystem beforemain, sourl(forResource:withExtension:)+Data(contentsOf:)read them. Both classes live in_FoundationCommon, shared by FoundationLite and the bridge.
Phase 12: the rest of the Hacking with SwiftUI list#
- The survey's remaining gaps, closed:
@FocusStatewith.focused(_:)and.focused(_:equals:)(the field'sfocusedprop;focusevents back),.onSubmitand.submitLabel(submitevents on Enter,enterkeyhint),.preferredColorScheme(a root update the page applies as its theme),navigationTitlewith aBindingorText,.accessibilityInputLabels,.scrollTargetBehavior+.scrollTargetLayout()(CSS scroll snapping on the target stack's children),.containerRelativeFrame(an affine size against the nearest container: the screen or the scroll viewport),.visualEffect(its effects apply once with an empty proxy; the browser does not report frames per scroll step),@Environment(Type.self)with.environment(_:)for@Observableobjects,dynamicTypeSize(_ range:),Angle / Double,.scrollBounceBehavior, and aUITextCheckerstand-in that passes every word (full mode). - CLI:
import Foundationalso imports Observation, as Apple's does (an@Observablemodel in a Foundation-only file compiles), and file references whose case differs from the disk are repaired (a project made on macOS). - Twelve of the nineteen Hacking with SwiftUI projects now compile unchanged
(WeSplit, Guess the Flag, Views and Modifiers, WordScramble, Animations,
iExpense, Moonshot, Navigation, AccessibilitySandbox, Flashzilla,
LayoutAndGeometry, SnowSeeker). The rest wait on frameworks: SwiftData (11,
12, 16), Core ML (4), Core Image / PhotosUI / StoreKit (13), MapKit /
Core Location (14), and networking (
URLSession,AsyncImage; 10).
Phase 13: networking#
The browser is the network. URLSession (data(from:), data(for:),
upload(for:from:), dataTask(with:completionHandler:)), URLRequest,
URLResponse / HTTPURLResponse and URLError exist in both Foundation
modes and run over the page's fetch: a request becomes a request op the
page performs and answers with a response event, and the awaiting task
resumes through the executor. AsyncImage (all three initializers,
AsyncImagePhase) asks the page to decode the picture and shows an <img>
the browser already has. Cross-origin servers must allow the page (CORS), as
for any web app. Headless runs answer from SB_RESPONSES fixtures, so an
app's network paths snapshot deterministically. Cupcake Corner (project 10)
is the thirteenth Hacking with SwiftUI project to run unchanged, and the new
Examples/CupcakeCorner.
Phase 14: one Foundation#
Lean mode is gone. Every app links the wasm SDK's Foundation with its ICU
data trimmed to the app's languages, the way the CLI has built apps by
default since Phase 11; 4 of the 13 Hacking with SwiftUI projects that run
today compiled in lean mode, and each new Foundation API an app touched had to
be re-implemented in FoundationLite. With one Foundation the shim imports it
directly: onDelete takes an IndexSet, DatePicker a Date,
Text(_:format:) a FormatStyle, and the three protocols that stood in for
them, FoundationLite, _FoundationCommon and the bridge's conformance layer
are deleted. FoundationBridge keeps what corelibs Foundation lacks on WASI:
Timer and RunLoop, a persisting UserDefaults, Bundle, URLSession,
AsyncImage, the viewer's Locale. The examples are now built through the
CLI like any app (scripts/build-wasm.sh), their sources say
import Foundation and nothing else, and the headless snapshots are recorded
in en_US with ICU's formatting. --foundation and "foundation" in
swiftbrowser.json are accepted and ignored with a warning. A small app is
18 MB, 4.6 MB over the wire with brotli.
Phase 15: smaller modules#
Where the 18 MB went, and why the linker cannot drop more, is in
docs/ops-protocol.md, "Phase 15": the app is 6 kB of it; the rest is the
standard library, Foundation, ICU and their protocol conformances, which stay
reachable through dynamic casts. What shrank: the ICU trimming also drops the
legacy character-set converters, StringPrep profiles and spoof-checker data
(0.8 MB), "collation": false drops the collation tables (0.9 MB) for apps
that never compare strings in a locale, wasm-opt runs -Oz (as fast as -Os
here, 1.3% smaller) and the Swift driver's autolink record is removed
(108 kB). Counter: 17.4 MB (4.5 MB brotli), from 18.4 MB (4.6 MB). Loading
the ICU data from a content-named file beside the module, which every app on
a site shares and an update never re-downloads, was built and measured too
(13.6 MB module plus a 3.9 MB file) and parked on the icu-sidecar branch:
for a single-app site it saves a fifth of each update's download at the cost
of a second file the module cannot start without.
Phase 16: skipping unchanged views#
A body that re-runs no longer re-runs every view below it. Like SwiftUI, the runtime compares each child's new view value and the environment it receives with last time's, and skips the child and its subtree when they are the same (docs/ops-protocol.md, "Phase 16", has the rules; a closure or binding always counts as a change). In Food Truck a new order re-rendered the whole Truck screen, a task long enough to stall the animated truck header; now only the orders card and what changed in it re-run.
Phase 17: cheaper passes#
Each render pass does less (docs/ops-protocol.md, "Phase 17"). The runtime
works out once per view type which of its protocols the type conforms to,
instead of two dozen dynamic casts per node per pass. A chain of modifiers no
longer doubles its work with every modifier. A screen hoists its toolbar items
only from elements that are new. The renderer no longer reads every updated
element's computed style in passes that cannot animate, and it reads the
screen size only after a resize. Food Truck's Truck screen, with its header
animating, takes about 95 ms of script per second instead of about 140. The
new-order task's longest piece drops from 55–74 ms to 50–55 ms. Two
behaviors were fixed along the way: a modifier inside an if was applied
twice, so onAppear and .task ran twice. A long modifier chain took time
exponential in its length; thirty .paddings never finished rendering.
Phase 18: SwiftData on IndexedDB#
import SwiftData works: @Model, ModelContainer, ModelConfiguration,
ModelContext (insert, delete, delete(model:where:), fetch with a
FetchDescriptor, fetchCount, save, rollback, autosave), @Query (sorted,
filtered, made in init from a view's inputs), #Predicate, SortDescriptor,
@Attribute(.unique), @Relationship with its delete rules and inverse
(declared or inferred), @Transient, Codable attribute types and
.modelContainer / .modelContext on views and scenes. The models live in
memory while the app runs; the page keeps them in IndexedDB, one database per
app, reads it before the module starts (SB_SWIFTDATA) and writes every save
back (a swiftdata op), so the data survives reloads and new builds. The main
context saves at the end of each event that changed it.
@Model is a real macro that expands as Apple's does (Sources/SwiftDataMacros,
on swift-syntax 604, which SwiftPM downloads prebuilt for Swift 6.4), so an app
links SwiftData only when it imports it. #Predicate gets SwiftData's copy of
Foundation's macro, which also accepts localizedStandardContains,
localizedCompare and caseInsensitiveCompare (the open-source macro rejects
them). Bookworm and SwiftDataProject (Hacking with SwiftUI 11 and 12) now run
from their Xcode projects unchanged, fifteen of the nineteen, and
Examples/Bookshelf exercises all of it. docs/ops-protocol.md, "Phase 18", has
the details.
Smaller additions: sharing, empty states, framework stand-ins#
ContentUnavailableView(title and symbol or image, description, actions,.searchand.search(text:)), built from the shim's own views.ShareLink(one item or several,String,URLand asset-catalogImageitems,SharePreview, subject and message) asks the page to share through the Web Share API. Where the browser has none or refuses, it downloads instead: an image as its file, text and links as a.txt. A share the viewer dismisses downloads nothing (docs/ops-protocol.md, "Sharing").Three Apple frameworks, as far as apps usually use them:
import StoreKit:@Environment(\.requestReview)andSKStoreReviewController.requestReview()do nothing (there is no store page to review).import CoreLocation:CLLocationCoordinate2Dis a plain struct, with no location services behind it.import LocalAuthentication: anLAContextwhose every policy can be evaluated and whose every evaluation succeeds. An app that locks content behind Face ID opens as if the viewer had authenticated.
Each is a small module the CLI links only into apps that import it.
Examples/Galleryhas a Sharing screen.
Phase 19: the app's files#
An app gets an iOS-like home directory that the page keeps across launches.
URL.documentsDirectory, URL.applicationSupportDirectory,
URL.cachesDirectory and URL.libraryDirectory (which corelibs Foundation does
not declare) point into it, and so do FileManager.urls(for:in:), so
Data.write(to:), Data(contentsOf:), String(contentsOf:) and FileManager
work as on a phone. Data.WritingOptions.atomic, which corelibs marks
unavailable on WASI, is accepted (writes there are always in place). The page
mounts the home at /data from the app's IndexedDB database
(sb-files:<App>) and, after any event that wrote to a file, saves what changed;
/tmp is a separate mount nothing keeps; ?files=reset starts with an empty
home. Headless runs map the home to a directory (SB_FILES_DIR).
Examples/Journal keeps its entries in Documents the way Hacking with
SwiftUI's BucketList does. docs/ops-protocol.md, "Phase 19", has the details.
Core ML for small Create ML models#
import CoreML works for the models Create ML makes from tables: linear and
logistic regressions, boosted trees, random forests and decision trees, with
the feature engineering their pipelines wrap them in (one-hot encoders,
imputers, normalizers, scalers, vectorizers). Xcode turns each .mlmodel of
a target into a Swift class; the CLI does the same when it stages the app. It
reads the model's protobuf (a small wire-format reader, no dependency),
checks every model in it is one the browser can run, and generates the class
with Xcode's API (init(configuration:), prediction(<inputs>),
prediction(input:), predictions(inputs:), load, model,
urlOfModelInThisBundle) and the model's specification embedded as JSON. A
new CoreML module (MLModel, MLModelConfiguration, MLFeatureProvider,
MLFeatureValue, MLDictionaryFeatureProvider, MLMultiArray,
MLModelDescription, MLModelError) evaluates it, synchronously and in
double precision; it is linked only into apps that have a model or import
CoreML. A model of another kind (a neural network, an ML program, an image or
text model) stops the build with its type named. BetterRest (Hacking with
SwiftUI 4) now runs from its Xcode project unchanged, sixteen of the
nineteen: at its defaults SleepCalculator predicts 8 h 21 min 48 s of sleep
and the alert says 10:38 PM, which CI checks headless. docs/ops-protocol.md,
"Core ML models", has the details.
Photos picker#
import PhotosUI works: PhotosPicker (one item or several, with
maxSelectionCount, a PHPickerFilter such as .images, .videos,
.any(of:) or .not(_:), a photoLibrary, a label or a title) and
.photosPicker(isPresented:selection:). The browser's file chooser stands in
for the photo library. A tap on the picker sends a pickFiles request, and
the page opens an <input type="file"> within that same tap, since browsers
only show a chooser while the user is tapping. The page answers with every
picked file's bytes. PhotosPickerItem is Hashable, and
loadTransferable(type: Data.self) returns the bytes at once.
loadTransferable(type: Image.self) shows the picture. A dismissed chooser
leaves the selection as it was. supportedContentTypes are UTTypes from a
small UniformTypeIdentifiers module. Headless runs answer picks from
SB_RESPONSES fixtures. A task an onChange(of:) action starts during a
render, such as one loading the picked item, now runs right away instead of
at the next event. docs/ops-protocol.md, "Photos picker", has the details,
and Examples/PhotoPicker uses all of it. Instafilter (Hacking with SwiftUI
project 13) also uses Core Image and @AppStorage, both below.
Bitmaps and Core Image#
UIImage, CGImage and import CoreImage (with CoreImage.CIFilterBuiltins)
work as Hacking with SwiftUI's Instafilter (13) and Hot Prospects (16) use
them: UIImage(data:) from an image file's bytes, CIImage(image:), the
built-in filters sepiaTone, crystallize, edges, gaussianBlur,
pixellate, unsharpMask and vignette (typed, or by name with
setValue(_:forKey:) and inputKeys), qrCodeGenerator (byte mode, error
correction L, M, Q or H), CIContext().createCGImage(_:from:),
UIImage(cgImage:) and Image(uiImage:) with .interpolation(.none).
ShareLink shares such an image as a PNG.
Swift never holds the pixels. A UIImage or CIImage is a recipe: the file's
bytes (or a pixel buffer, for a QR code) plus the filter steps to run, which
the page carries out on a canvas at the size the image is shown
(Web/src/imageOps.ts, in plain TypeScript). UIImage(data:) stays synchronous,
as on iOS, because Swift only reads the size from the file's header (PNG,
JPEG with its EXIF rotation, GIF, WebP, BMP, HEIC, AVIF). The QR code
generator makes its modules in Swift. UIImage and CGImage live in the shim,
since SwiftUI re-exports them on iOS; Core Image is a module of its own, linked
only into apps that import it, like the stand-ins above. Examples/Filters
runs Instafilter's processing code on a bundled photo next to Hot Prospects'
QR code. docs/ops-protocol.md, "Bitmaps and Core Image", has the protocol and
each filter's formula.
Swift packages, notifications and a code scanner#
- An Xcode project's Swift package dependencies now reach the build. The CLI
used to drop them (an app importing one failed with "no such module"); now
each remote package becomes a real SwiftPM dependency of the staging package
with the version rule the project records (local packages by path), so a
pure-Swift package that builds for WASI is fetched and compiled in. Packages
that cannot work in the browser are replaced by a SwiftBrowser module of the
same name instead;
checksays which way each package went (docs/ops-protocol.md, "Swift package dependencies"). import CodeScanner(Paul Hudson's camera package) gets such a stand-in:CodeScannerViewwith the package's whole initializer,ScanResult,ScanError,ScanModeandAVMetadataObject.ObjectType. It shows what the package shows in the iOS simulator, a screen that sends back thesimulatedData, and where the browser hasBarcodeDetectorand a camera (Chrome on Android and macOS) it can scan for real (docs/ops-protocol.md, "Code scanning").import UserNotificationsworks over the browser's Notification API: authorization and settings, time-interval and calendar triggers (repeating too), pending and delivered requests, the badge. The app schedules its own timers and the page shows each notification as it comes due, so they are delivered only while the page is open (docs/ops-protocol.md, "Notifications").Examples/HotProspectsis Hacking with SwiftUI project 16 without its Me tab: scan the simulated QR code, swipe for "Remind Me", and the notification appears five seconds later. With Core Image and@AppStorage(below) the project itself builds unchanged, Me tab and all.
@AppStorage#
@AppStorage("key") var value = default reads and writes UserDefaults, so
it is kept across launches like the rest of the app's defaults (Phase 11's
defaults op, in localStorage). It takes what SwiftUI's does: Bool, Int,
Double, String, URL, Data and Date, RawRepresentable enums over
Int or String, their optional forms (@AppStorage("key") var value: String?, where nil removes the key) and store:. A read registers with the
observation tracking each body runs under, and every write to the key,
through any @AppStorage or through UserDefaults.set, re-renders the
views that read it, and only them. With it Instafilter (13) and Hot
Prospects (16) build from their Xcode projects unchanged: eighteen of the
nineteen Hacking with SwiftUI projects, all but Bucket List (14, MapKit).
CI runs Hot Prospects headless: a name typed into the Me tab is saved, its
QR code is made again, and a relaunch shows the name.
That run found a runtime bug: state an onChange(of:) action set while its
body ran (Hot Prospects' updateCode) re-ran the body in the next pass but
never reached the page when the view sat below a clean ancestor, such as a
tab of a TabView. The pass that marked it cached the ancestors' elements
again afterwards; the reconciler now drops the cache of every node it
re-enters.