Skip to content

Add synchronized folders, and write project.pbxproj in Xcode's form - #3373

Closed
erneestoc wants to merge 1 commit into
MobileNativeFoundation:mainfrom
erneestoc:ec/synchronized-folders
Closed

erneestoc wants to merge 1 commit into
MobileNativeFoundation:mainfrom
erneestoc:ec/synchronized-folders

Conversation

@erneestoc

@erneestoc erneestoc commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Adds support for Xcode 16's buildable folders (PBXFileSystemSynchronizedRootGroup) through a new xcodeproj_synchronized_folders aspect hint, and makes the generated project.pbxproj byte-for-byte what Xcode writes, so Xcode doesn't rewrite it when a synchronized folder's contents change.

This takes a similar approach to #3315, with a few differences described below, including a cause for the slowdown reported there.

Usage

load("@rules_xcodeproj//xcodeproj:xcodeproj_synchronized_folders.bzl", "xcodeproj_synchronized_folders")

# Folders are relative to the package of each target using the hint, so one hint
# can be shared by every target with the same layout.
xcodeproj_synchronized_folders(
    name = "sources_folder",
    folders = ["Sources"],
)

swift_library(
    name = "Feature",
    srcs = glob(["Sources/**/*.swift"]),
    aspect_hints = [":sources_folder"],
)

Files added to Sources/ (in Xcode or on disk) appear in the target without regenerating the project, and Bazel builds them through the glob. No changes to rules_swift or other rules are needed.

Synchronized folders

  • Aspect: reads the hint's folders, records them as the target's synchronized_folders (carried through target merging, so a test's library folder ends up on the test target), and drops the target's sources inside them from its per-file list. A target with folders counts as having sources (no compile stub).
  • files_and_groups: creates a PBXFileSystemSynchronizedRootGroup for each folder and skips any other path inside it (e.g. extra files), so a folder never also appears as a group of files.
  • pbxnativetargets: writes fileSystemSynchronizedGroups on the target. Both generators compute the folder's identifier from its path alone (Identifiers.FilesAndGroups.synchronizedRootGroup).
  • pbxproj_prefix: projects with synchronized folders use objectVersion = 70, which is what Xcode writes for them with our compatibilityVersion; other projects keep their current version.

Writing project.pbxproj in Xcode's form

When a synchronized folder's contents change, Xcode serializes the project and rewrites project.pbxproj if its bytes differ from the file on disk. With rules_xcodeproj's output that happened on the first change after every generation: a full re-save of the project file, which for large projects is tens of megabytes. Once the file matches Xcode's serialization, adding and removing files causes no writes at all.

The only content difference was lastKnownFileType for unknown extensions; the rest was layout:

  • Assembly: a final step (assemble_project_pbxproj.sh, a streaming awk/sort pipeline) replaces cat. It groups objects into /* Begin <isa> section */ blocks sorted by identifier and sorts each multi-line object's attributes with isa first (the PBXProject is written by several generators). It's idempotent and fails on malformed input.
  • Comments: the project's build configuration list comment includes the project name (pbxproj_prefix gets --project-name), and groups are commented with their name before their path (external repositories were commented with their resolved cache path).
  • File types: unknown extensions get lastKnownFileType = text instead of file. Xcode infers the type from contents, which the generator can't see; unknown files in source trees (.graphql, scripts, LICENSE) are almost always text. A binary file with an unknown extension would still differ.
  • TargetAttributes are sorted by identifier, as Xcode does.

This changes the layout of every generated project, not just ones with synchronized folders: the first regeneration produces a large diff, after which the file matches what Xcode writes. The assembly step takes ~0.16 s for a 1 MB project file and ~3.8 s for a 43 MB one (almost all in macOS awk), once per generation.

Performance

On a synthetic project (200 targets, 3,000 files, 200 synchronized folders, 1 MB project.pbxproj) with Xcode 26.2 open:

  • Before the canonical output, the first add/remove after each generation made Xcode rewrite the project (about half a second here; much more for large projects). After it, no change wrote the file.
  • Each add/remove still costs Xcode ~1.5–2.5 s of CPU on its own (re-evaluating target membership), regardless of rewrites. That is inherent to synchronized folders and grows with project size.

The 5–6 s hangs reported in #3315 are likely a mix of both; this PR removes the rewrite part.

Testing

  • Unit tests for the new identifier, the synchronized folder element and path filtering, fileSystemSynchronizedGroups, the object version, the hint rule, and the assembly script (objects split across partials, PBXProject written in parts, idempotence, malformed input). bazel test //tools/... //test/... passes (103 tests), as does //docs:diff_test.
  • An iOS app (a rules_apollo example) with a feature library and its tests using the hint, Xcode 26.2:
    • xcodebuild test passes;
    • a file added on disk appears in the target's SwiftFileList and is built by Bazel without regenerating;
    • jump to definition works in synchronized folder files;
    • the generated project.pbxproj is identical to Xcode's own serialization, and adding/removing files leaves it untouched.
  • The synthetic 200-target project above: no rewrites on add/remove.

Limitations

  • Bazel builds the target's srcs, so they should cover the folder (e.g. glob(["Sources/**/*.swift"])); a file added in Xcode outside the glob appears in the target but isn't built.
  • Xcode treats every file in a synchronized folder as a member of the target; non-source files aren't excluded yet (that would need exception sets).
  • Generated folders, platform-conditional sources, and unfocused targets aren't handled yet.

@erneestoc
erneestoc requested a review from a team as a code owner October 9, 2026 06:30
@erneestoc erneestoc mentioned this pull request Oct 9, 2026
@adincebic

Copy link
Copy Markdown
Contributor

Did you test Xcode UI performance? I had this implemented internally and adding / deleting files in larger modules caused Xcode to hang for 5-6 seconds.

@erneestoc

Copy link
Copy Markdown
Contributor Author

I tested first on a big bug fake project I generated. The re-save stopped happening after I was able to match the codegen to what Xcode actually produces - but it may differ on a bigger real-world project.

I just tested now on my actual project, and I'm not seeing any re-save or performance issue. Files changes reflects correctly.

Adds the `xcodeproj_synchronized_folders` aspect hint, which shows a target's
source folders in Xcode as synchronized folders (Xcode 16's buildable folders,
`PBXFileSystemSynchronizedRootGroup`), so files added to them appear in the
target without regenerating the project. Bazel keeps compiling the target's
`srcs` (typically a `glob` of the folder), so no rules changes are needed.

- The aspect reads the hint's folders (relative to the target's package),
  records them as `synchronized_folders` inputs (carried through target
  merging), and drops the target's sources inside them from its file list.
- files_and_groups creates a `PBXFileSystemSynchronizedRootGroup` for each
  folder, with an identifier derived only from its path, and skips other
  paths inside it; pbxnativetargets references the same identifiers in the
  target's `fileSystemSynchronizedGroups`. Targets with folders don't need
  the compile stub.
- Projects with synchronized folders use `objectVersion = 70`, which is what
  Xcode writes for them; other projects keep their version.

Also writes `project.pbxproj` exactly as Xcode serializes it. When the
contents of a synchronized folder change, Xcode rewrites the whole project
file if its serialization differs from the file on disk, which for large
projects means re-saving tens of megabytes after each generation.

- A final assembly step replaces `cat`: it groups objects into
  `/* Begin <isa> section */` blocks sorted by identifier and sorts each
  object's attributes, as Xcode does.
- Comments match Xcode's: the project's build configuration list includes the
  project name, and groups are commented with their name before their path.
- Files with unknown extensions get `lastKnownFileType = text`, which Xcode
  infers for text files.
- `TargetAttributes` are sorted by identifier.

Signed-off-by: Ernesto Cambuston <e.cambuston@gmail.com>
@erneestoc

Copy link
Copy Markdown
Contributor Author

Split into smaller PRs that can be reviewed and merged independently:

#3374 and #3375 don't depend on the others.

@erneestoc erneestoc closed this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants