What a view becomes once SwiftUI has it: a handful of numbered nodes, edges made by reads, and one zero-byte node per @State. Every number on these pages was read out of a running program.
Start here. This is one view, the app it makes, and a debugger stopped inside SwiftUI while that app runs. The numbers in the comments are attributes: the ids AttributeGraph gave the nodes it built for this view. The pages after this one explain what they are and what happens to them.
Trip.swift · tap a line to find its node3152 to show or hide what's below it| Attribute | Rule | Value | Reads | Read by | Its job |
|---|---|---|---|---|---|
| 800 lines 1–2 | none (_External) | Trip, 96 bytes: your struct as it was handed in. All three fields are still .thunk with _location: nil. | — | 3152 | The view's value. When a parent hands over a new Trip, it lands here. |
| 3152 lines 7–8 | DynamicBody<ViewBodyAccessor<Trip>> | Text, 60 bytes: "Lisbon, 3 nights, false" | 624, 800, 3032, 3096, 3124 | 3216, 3256 | The view's body. Running its rule calls your body; the value is what body returned. |
| 3032 line 3 · written on 14, 16 | none (_External) | (), 0 bytes | — | 3152 | The signal for nights. Invalidating it is how a write reaches the graph. |
| 3096 line 4 | none (_External) | (), 0 bytes | — | 3152 | The signal for city |
| 3124 line 5 · written on 15 | none (_External) | (), 0 bytes | — | 3152 | The signal for favorite |
| 624 not in your code | none (_External) | Phase, 4 bytes: Phase(value: 0) | — | 3152 and 5 more | SwiftUI's own input: a reset counter and an "is being removed" bit. |
The five nodes on top have no inputs: nothing in the graph feeds them. They are the graph's inputs, external nodes that SwiftUI writes from outside. In this window there are 26 of them, among them the environment (576), the time (536), the root origin and size (856, 904) and the safe-area insets (1080). Trip is the root view in these dumps, so its struct, 800, is one of those: NSHostingView writes it with the struct it was given, and no node sits above it.
A view inside a parent looks different, and this is the real graph of one. Planner is the root; two Editor children get $nights. The parent's body node, 3096, holds the children's structs inside its value, the VStack's tuple. Each child's body node reads 3096 directly. That is what sits above a nested view: its parent's body node, not a node of its own.
Below, the app on the left is running. Tap something in it and the debugger on the right stops at each breakpoint in the real call order, with the stack, the variables and the graph's flags at that moment. The six breakpoints that are on are the ones in the Xcode project; switch on the others to stop everywhere.
Tap something in the app.What's real and what's tidied. The stops, their order and the call stacks are the ones lldb recorded; the values are cleaned up for reading. The node numbers and flags are from the dumps of the one-Text Trip on page 3; the three tap targets are the only addition, and the nodes SwiftUI makes for them are left out. Tap gestures are used instead of Buttons because a Button keeps @State of its own and one click writes it five more times.
AttributeGraph is the private C++ engine under SwiftUI. Everything it tracks is an attribute, and in code an attribute is nothing but a 32-bit integer, AGAttribute. The node is the record that integer leads to: a rule to run, a value, some flags, and edges.
Attribute and node are two views of one thing. The number is the handle your code holds; the node is the record AttributeGraph keeps for it. The handle is an offset: AttributeGraph keeps all its nodes in one block of memory, in 512-byte pages, and the node lives at start of block + id. The open-source reimplementation spells it out as base + _offset; Apple's AGGraphInvalidateValue passes the id straight through as the node pointer.
These are the ids from one small window on this Mac. Click one, or type any number.
Each row is one 512-byte page of AttributeGraph's block. A tick marks the first byte of a node; the id is that byte's distance from the start of the block. Click a tick to inspect it. The pages in between hold SwiftUI's other 44 nodes for this window, left out here.
Nodes are aligned, so a real node's offset always ends in binary 00. AttributeGraph uses those two free bits as a tag. The reimplementation's AttributeID.hpp names them, and the first instruction of AGGraphInvalidateValue tests them.
| Low 2 bits | Kind | What it is | Seen |
|---|---|---|---|
| 00 | direct | An ordinary node with its own value, and a rule if it computes it. | 624 · 800 · 3032 · 3152 … |
| 01 | indirect | No value of its own. It says "my value is that other node's value, starting N bytes in." SwiftUI makes one with AGGraphCreateOffsetAttribute when it wants a field of a value, and uses re-pointable ones as an AnyView's outputs. | 3281 · 7397 |
| 10 | null | The empty attribute. An AnyView set each of its indirect outputs to 2 before pointing them at new content. | 2 |
AGGraphInvalidateValue does with the numberTrimmed from Apple's binary. Three checks, then a jump into the C++ engine with the id passed through as a node pointer.
| Type | Adds | Where you met it |
|---|---|---|
AGAttribute | Nothing. The 32-bit number. | The argument to every AGGraph… C call. |
Attribute<Value> | The value's type, at compile time only. | DynamicBody's container and phase fields. |
WeakAttribute<Value> | A seed number, so a handle that doesn't keep the node alive can tell whether it still exists. | _signal: WeakAttribute<()> = #3032, seed: 2, inside a @State's location. |
IndirectAttribute<Value> | A source you can re-point. | An AnyView's outputs. |
Same trick as key paths. A key path stores a property as a byte offset from the start of its struct. AttributeGraph stores a node as a byte offset from the start of its block. Both get a small, stable number instead of a 64-bit address.
A view with three @State properties becomes five nodes: one for the struct, one for the body, and one empty node per @State. Here is the view, and then the graph AttributeGraph wrote out for it.
Trip, as run on this MacAGGraphArchiveJSONAn arrow means "is read by". Click a node for its record. Dashed nodes have no rule: SwiftUI sets their values from outside. Blue nodes are the signals.
| Attribute | Rule | Value | Reads | Read by | Its job |
|---|---|---|---|---|---|
| 800 | none (_External) | Trip, 96 bytes: your struct as it was handed in. All three fields are still .thunk with _location: nil. | — | 3152 | The view's value. When a parent hands over a new Trip, it lands here. |
| 3152 | DynamicBody<ViewBodyAccessor<Trip>> | Text, 60 bytes: "Lisbon, 3 nights, false" | 624, 800, 3032, 3096, 3124 | 3216, 3256 | The view's body. Running its rule calls your body; the value is what body returned. |
| 3032 | none (_External) | (), 0 bytes | — | 3152 | The signal for nights |
| 3096 | none (_External) | (), 0 bytes | — | 3152 | The signal for city |
| 3124 | none (_External) | (), 0 bytes | — | 3152 | The signal for favorite |
| 624 | none (_External) | Phase, 4 bytes: Phase(value: 0) | — | 3152 and 5 more | SwiftUI's own input: a reset counter and an "is being removed" bit. |
Not in any of these nodes. Node 800 holds the struct as the parent built it, so each field is still a .thunk, the closure the @State macro wrapped around your initial value, with no location. The values live in three StoredLocation objects that SwiftUI keeps on the body node's rule. Before it calls your body, SwiftUI copies the struct out of 800 and writes those locations into the copy. A Mirror of self taken inside body shows the result:
__nights: LazyState<Int> ├ _storage: .value(3) ← the thunk has run └ _location: StoredLocation<Int> ← a class: the reference ├ host: GraphHost (a ViewGraph) ← owns this window's graph └ _signal: WeakAttribute<()> = #3032 └ _details ├ identifier: AGAttribute = #3032 ← the zero-byte signal node └ seed: 2 __city … _signal = #3096 __favorite … _signal = #3124
So a @State holds a reference to its location object, and that object holds a weak handle to its own signal node. The struct never holds the body node's number.
The rule is a struct, DynamicBody, stored inside node 3152. Its fields last from one run to the next. From the OpenSwiftUI reimplementation:
Each @State field is 32 bytes: a LazyState is an enum (the thunk or the value) plus an optional reference. The one-@State Counter from the breakpoints project was 32 bytes; Trip is 3 × 32.
The @State macro is what makes the thunk. For @State private var nights = 3 it generates:
Creating the struct doesn't evaluate the initial value. It runs once, when SwiftUI first installs the location.
A 32-bit number SwiftUI sets from outside. Bit 0 is isBeingRemoved; bits 1–31 are resetSeed, a counter. When the counter changes, the body node drops every @State location and starts fresh, which is how SwiftUI can reuse a row's nodes for a different row without leaking its state. _printChanges labels this input @identity and the struct node @self, so ContentView: @self, @identity, __favoritesOnly changed. names three of the inputs you see above.
A modifier doesn't change a view. It wraps it: each call returns a new value, ModifiedContent<Content, Modifier>, with the view inside and the modifier beside it. The nesting is written out in the type, and AttributeGraph turns every layer into nodes of its own. Here is a chain of four, in a view with nothing else in it, so that Card is the root of the graph.
Card, as run on this Mac, in a 300 × 200 windowThe type after each step, printed with type(of:):
Image("photo") Image .resizable() Image ← no wrapper: a setting inside Image .scaledToFill() ModifiedContent<Image, _AspectRatioLayout> .frame(height: 120) ModifiedContent<ModifiedContent<Image, _AspectRatioLayout>, _FrameLayout> .clipShape(RoundedRectangle(cornerRadius: 8)) ModifiedContent<ModifiedContent<ModifiedContent<Image, _AspectRatioLayout>, _FrameLayout>, _ClipEffect<RoundedRectangle>>
Three things to read off that:
.resizable() is the exception. It's a method on Image that returns another Image, with the setting stored inside. Four modifiers, three ModifiedContent layers.content (everything inside) and modifier. The whole expression is one value, nested like Russian dolls, built from the inside out.ModifiedContent<ModifiedContent<ModifiedContent<Image, _AspectRatioLayout>, _ClipEffect<…>>, _FrameLayout>, and draw differently: the clip is applied to the image before the frame sizes it.AGGraphArchiveJSONCard is the root, 800. The window's graph has 64 nodes; these are the 16 the body and its four layers produce. Tap a node: the arrows into it are what it reads, the arrows out of it are who reads it, and everything else fades. The layout column is on the left, the drawing column on the right.
| Code | Nodes | What they hold |
|---|---|---|
Image(systemName:) + .resizable() | 3864, 4752 | The resolved image with accessibility attached, 344 bytes, and a layout computer for how big it wants to be. .resizable() has no node of its own. |
.scaledToFill() | 3684, 3724 | A layout computer that keeps the aspect ratio and fills the space, and a geometry node for where the image goes inside it. |
.frame(height: 120) | 3392, 3488, 4824 | A layout computer for a 120-point-tall box, a geometry node for where its child sits inside it, and the box's frame in the window. |
.clipShape(…) | 3120, 4980 | The clip shape as an animatable value, and a display list that wraps the image's drawing in (effect (clip …)). |
| the window | 1400, 4120, 4424, 4712, 5184 | The root geometry, the image's frame, the image's display lists, and the root display list that the window draws. |
RootDisplayList (5184) reads. The clip cuts the 240-point image down to the 120-point frame with rounded corners; its path starts at (300, 60), the first rounded corner.StaticBody, not DynamicBody. Card has no @State, so its body node is a StaticBody: no signals, no property buffer, nothing to write into the struct before body runs. Add one @State and it becomes the DynamicBody from page 3, with a signal node beside 800.
Nobody declares that the body node depends on nights. The edge appears the first time the body node's rule reads the signal, because every read goes through AttributeGraph, and AttributeGraph knows which node is running.
The read function names only the node being read. There's no "reader" parameter:
So when 3152 reads 3032, 3152's own code doesn't touch 3032's lists. AttributeGraph does, inside the read: it looks up the current attribute, records the input on the reader and the output on the node read, and returns the value. Both directions are stored, as AG::InputEdge on the reader and AG::OutputEdge on the node read.
This is the first run of the Trip body node, in the order lldb saw the calls. Each step is one event; the console shows the trace line and the lists show what each node now records.
Three reasons, all visible in the trace order:
| Order of events at launch | What it means |
|---|---|
| 3 × AGGraphCreateAttribute | The three signals are made first, by each @State's _makeProperty, while SwiftUI builds the property buffer. The body node doesn't exist yet. |
| AGGraphCreateAttribute → 3152 | Then the body node is made, with the finished buffer as its links. To AttributeGraph the rule is a struct it stores and runs; it never searches the struct's fields for attributes. "Property of 3152" is a SwiftUI relationship, not a graph one. |
| first run → 5 × add_input | The StoredLocation that can invalidate a signal is created during this same first run, in the function that then reads the signal. Before the run, nothing can invalidate 3032, so no notification is lost by waiting. |
Used when the node runs. Each input edge carries a "changed" flag, so the rule can ask which inputs changed since last time. That's how _printChanges can say __favoritesOnly changed.
Used when the node is invalidated. AttributeGraph walks 3032's outputs to find 3152 and mark it, then 3152's outputs, and so on. Page 4 shows that walk.
@ObservableAn @Observable property is not a node, so reading it cannot make an edge. SwiftUI gets the same effect around the body. Before the body node's rule runs, it asks AGGraphGetCurrentAttribute for the node's number and opens a list on the thread; the getter the macro wrote adds the observable object's registrar and the key path to that list; and when the body ends, SwiftUI gives each observable object's registrar in the list a closure holding the number, to call AGGraphInvalidateValue on it when the property changes. The six steps, drawn in order, are in the guide:
@Observable, in fullThe six steps around one read of an observable property: the list on the thread, the observable object’s registrar, and the closure that holds the view’s node.Open the guide →
nights += 1 changes nothing in the graph. It stores the value and leaves a note. Later, in the window's layout pass, SwiftUI drains the notes, invalidates one zero-byte node, and AttributeGraph marks everything downstream. Then the marked nodes rerun, inputs first.
The graph was dumped four times during one write: before, right after the write, right after AGGraphInvalidateValue returned, and after body ran. The flags below are those dumps. Step through it.
Both were run under the same breakpoints. Press a button to see the trace.
Choose a variation above.| Which node | Word | What happens |
|---|---|---|
| 3032, the signal | invalidated | SwiftUI calls AGGraphInvalidateValue(3032). Its value is still () and its flags don't change; it now counts as changed. Apple's word: it's in the function name. |
| edge 3032 → 3152 | flagged changed | The edge gains a flag (8), so 3152 later knows which input changed. |
| 3152 and 8 below it | marked ("dirty") | Each gains bit 0, and all but 3152 gain bit 4: the rule must rerun before anyone reads the value. "Dirty" is this guide's word; Apple's internal name for the bit isn't known. |
Two targeted walks, not a tour of every view. The push starts from the changed signal and follows read-by edges outward; it only sets bits. The pull starts from what the window needs and follows input edges inward; only marked nodes run. A simplified model, not Apple's code:
The counts bear it out. AG::Graph::update_attribute ran 196 times while a small window first opened, when every node had to be computed once. After one tap changed one @State in a one-Text window, it ran 6 times.
A Binding owns no storage. It's a reference to a StoredLocation that some other view owns, and that location carries one signal. So every view holding a binding to it ends up reading the same signal, and the signal's read-by list grows.
Planner and its children, as run on this MacThe node ids and the printed lines are from the real run. Untick the box to make Planner's own body stop reading nights.
Three fields: transaction, location and _value. The one that matters is location. Inside Editor, SwiftUI wraps the parent's location once more so the child can track its own wasRead, but it wraps the same object:
Planner's $nights: location is StoredLocation<Int> 0x…c500 Editor's _nights: location is LocationBox<ScopedLocation> 0x…0200 which wraps StoredLocation<Int> 0x…c500 ← the same object
Reading nights in Editor calls location.get(). Writing it calls location.set(…), the same StoredLocationBase.set path as page 4: queue, flush, invalidate 3032. And when Editor's body node runs, its @Binding box asks the shared location to update, which reads the signal, and AttributeGraph records the reader as whichever node is running: Editor's.
@State var nights | @Binding var nights | let nights: Int | |
|---|---|---|---|
| Storage | Its own new StoredLocation and signal | None: a reference to the owner's | None: a copy inside the struct |
| Gets later changes from the parent | No. The starting value is used once; a child that copied nights into its own @State stayed at 3 while the parent went 4, 5. | Yes, from the shared location | Yes, when the parent reruns and builds a new struct |
| Can change the value | Only its own copy | Yes, the owner's value | No |
| Reads the owner's signal | No, it has its own | Yes | No |
With the box unticked, a write still marks Planner's body node, because its @State box reads the signal every run. But SwiftUI then skips calling body. The rule, from OpenSwiftUI's StatePropertyBox.update:
wasRead is set by the getter. Reading the storage some other way, through a Mirror for example, doesn't set it, and body doesn't rerun.
macOS 27.0, Xcode 27.0, SwiftUICore and the private AttributeGraph (version 8.0.76) from the shared cache. Graphs were written with AttributeGraph's own AGGraphArchiveJSON, looked up by name with dlsym, and diffed between snapshots. Call order and register values came from lldb breakpoints on AGGraphInvalidateValue, AGGraphGetValue, AGGraphGetCurrentAttribute, AG::Graph::add_input, AG::Graph::add_output_edge, AG::Graph::propagate_dirty and SwiftUICore's StoredLocationBase.set, GraphHost.asyncTransaction and flushTransactions. Struct contents came from Mirror. The Swift excerpts marked OpenSwiftUI are from that project's reimplementation, audited against SwiftUI 6.5.4; where its code and Apple's binary disagreed, the binary is reported. iOS was not traced; its host is _UIHostingView instead of NSHostingView.
The breakpoints project that walks this path in Xcode is AGBreakpoints on the Desktop.
body can run as often as it likes; onAppear and .task run once, when the view is inserted, and .task(id:) once per value. The thing that decides is a four-byte input every node reads, the phase, and a rule that queues an action instead of running your closure. This page is a second small program, measured the same way: a window on this Mac, its graph dumped, and breakpoints on SwiftUICore's own functions printing in order with the program's lines.
Three answers, from the loose to the literal.
| As a | A rule is |
|---|---|
| concept | The recipe for one node's value: a function from the values of the nodes it reads to its own. Every node that is not an input (_External) has one. Running the rule is the only way a node's value ever changes. |
| Swift type | A struct conforming to Rule (a value getter) or StatefulRule (a mutating updateValue()). Its stored properties are the Attribute handles of the nodes it will read, plus any state it keeps between runs. AppearanceEffect is one: init(modifier:phase:), two handles, and updateValue(). |
| bytes | That struct's bytes, stored inside the node, in one block with the value's bytes. The node's type id names an entry in the graph's type table, which holds the Swift metadata of both and the thunk that calls updateValue() on the struct in place. The graph never copies the rule out to run it. |
AttributeGraph is a private framework, and no interface for it ships: lldb finds no declaration for either protocol. What the binaries do export is every requirement, as the witness symbols of SwiftUICore's conformances, and every default member, as extension symbols. The declarations below are assembled from those names. Two lines come only from the OpenSwiftUI reimplementation and say so.
How the two map onto the nodes on these pages:
ResolvedTextFilter is a Rule: its value getter computes the resolved text, and the default updateValue() stores it. The body stack further down shows that default being called by the thunk from Attribute.init.AppearanceEffect and DynamicBody are StatefulRules: they keep state across runs, such as the phase seed they last saw, and their updateValue() writes through value only when there is something to store, which for AppearanceEffect is never, its value being ()._AttributeBody statics are what the dump's type entries hold: flags is the flags field of each type, comparisonMode decides how the graph tells "changed" from "same" after a run, and _updateDefault is what runs when a node is pulled with no rule of its own.The dump shows the bytes. Each type entry carries a size, and it is the rule plus the value: node 3096, the body below, is 180 bytes, and MemoryLayout of its value type says 152, so the DynamicBody rule is 28. The Trip body on page 3 was 60: the same 28 and a 32-byte Text. An AppearanceEffect node is 52 bytes with a value of (): all rule, no value. An input node like the phase is the other way round: 4 bytes of value, no rule.
Running a rule, then, is AG::Graph::update_attribute finding the node at its offset, reading its type entry, and calling the thunk with a pointer to the struct. The struct reads its inputs through its handles, each read an edge, and writes its result into the value bytes behind it. That is the whole of it: no callbacks, no observers, no list of things to run. A node runs when it is marked and something pulls it.
Attribute 624 on every page of this guide is _GraphInputs.Phase: four bytes, read by almost every node of a view. It is not a list of named stages. SwiftUICore's exported symbols for it are init(value:), isBeingRemoved, isInserted, resetSeed, merge(_:), invalid and ==.
| Part | What it is | When it changes |
|---|---|---|
| isBeingRemoved | one bit; isInserted is its opposite | set while the view's identity is on its way out of the graph, so its nodes know they are being torn down |
| resetSeed | the other 31 bits, a counter | bumped when SwiftUI resets a view: every node that reads it sees a new value and treats itself as freshly inserted, state included |
| invalid | a sentinel | "no phase yet" |
| merge(_:) | combines a parent's phase with a child's | as inputs flow down the tree, so a parent being removed shows in every child |
In the dumps on these pages its value is always Phase(value: 0): seed zero, not being removed. Writing nights never touched it. That is why page 6's write re-ran body and queued nothing.
From the SDK's interfaces. onAppear is one line, and .task is a sibling with no body of its own: its node, like the appear node, is built by _makeView.
The program for this page is a view with all three, and one @State:
Its graph, dumped right after the first insertion. The numbers are the dump's. Three AppearanceEffect nodes, one per modifier, and each has exactly two inputs: the phase and the node that carries its modifier. The task's is one step further down, because .task is implemented as a modifier whose own body applies an _AppearanceActionModifier: node 3240 is that body, with a signal of its own, 3208, for the @State that holds the running Task. The two under the body carry onAppear and onDisappear; the dump does not say which is which, so they are labelled in the order they were made.
Hover a node to see its edges. 3736 is the text's resolver, the one node here whose value the window needs: it is what pulls the body.
The same program, run under lldb with breakpoints on AppearanceEffect.updateValue, UpdateGroup.enqueueAction and AppearanceEffect.willRemove, each auto-continuing and printing a line. Timers changed the view four ways. Step through: the console is the real trace, lightly trimmed; the graph shows which rules ran.
The stack under each piece of a view with .onAppear and .task, printed from inside it and demangled. body runs inside a rule, pulled by another rule. The appear closure runs from the queue, after the graph update, inside the host's layout pass. The task closure runs from Swift concurrency, with no graph code above it at all.
Screen.body.getter ViewBodyAccessor.updateBody(of:changed:) StaticBody.updateValue() AG::Graph::UpdateStack::update AG::Graph::update_attribute AG::Graph::input_value_ref_slow AGGraphGetValue ResolvedTextFilter.updateValue() ← the pull
closure #1 in Screen.body.getter UpdateGroup.Action.callAsFunction() UpdateGroup.dispatchActions() ViewGraphRootValueUpdater.render(…) NSHostingView.layout() AppKit: the layout pass the task closure closure #2 in Screen.body.getter libswift_Concurrency: completeTaskWithClosure
A third program: a root view re-rendered by timers, counters in every closure, and a ScrollView of a thousand lazy cards with an onAppear each, removed before its .task had finished sleeping.
| Closure | Ran | Which closure |
|---|---|---|
body | 4× | once at insertion, once per model change, once when the cards left |
onAppear | 1× | the first run's |
.task | 1× | the first run's: it printed the value body saw the first time, though body ran three more times with new closures |
.task(id: n) | 3× | once per value of n, each the closure of the body run that produced that value |
card onAppears | 5 of 1000 | the rows the lazy stack made: the ones that fit the frame |
The scroll view's own .task started before any card had appeared, since the lazy rows are made in the next layout pass. And when the cards were removed before its second was up, SwiftUI cancelled the task, Task.sleep threw at once, try? swallowed that, and the print ran early with Task.isCancelled true. Check it after the sleep if the print should only happen for a view still on screen.
| Change | Phase | Rules seen running | Queued | You see |
|---|---|---|---|---|
| Insertion | arrives, isInserted | the body rule once, every modifier node, every AppearanceEffect | one action per effect: appear, an empty one for the disappear-only node, the task's start | body 1×, then onAppear, then the task |
| A state write | unchanged | the body rule and the nodes below it that draw. The effects: not once, though body made new closures | nothing | body +1 |
.id changes | the old identity leaves, a new one arrives | old: willRemove on each effect. New: everything as an insertion, with fresh @State | disappears for the old, appear and task for the new; the appears are dispatched first | body +1 with state back to 0, onAppear, the task, then onDisappear |
| Removal, the branch goes away | isBeingRemoved | each effect rule once more, then willRemove on each | the disappears; the task is cancelled | onDisappear |
| Reinsertion | back from being removed | didReinsert on each effect (from the symbols; not in these runs) | the appear again | onAppear again |
Why the closure runs from a queue and never inside the rule: it may change state. Changing state while the graph is half-way through computing would invalidate values it is in the middle of using. Run afterwards, any state it changes marks nodes, and the next pass picks them up.