Ship the Gutenberg JS bundle once instead of twice (−7.7 MB) - #25965
Conversation
The React Native runtime loads main.jsbundle from the app bundle root, but a byte-identical copy (~7.7MB) also ships inside Gutenberg.framework/App.js where it is never loaded — the framework resolves the bundle via RCTBundleURLProvider from the main bundle, not from itself (App.js is referenced zero times in the framework binary). Strip App.js from the framework at dependency-download time so it isn't embedded and code-signed into the app, and source the app-root main.jsbundle from the standalone react-native-bundle-source-map/main.jsbundle that already ships alongside the XCFramework. Removes ~7.7MB from the app bundle, no runtime change.
|
| App Name | WordPress | |
| Configuration | Release-Alpha | |
| Build Number | 34356 | |
| Version | PR #25965 | |
| Bundle ID | org.wordpress.alpha | |
| Commit | bcfdcd2 | |
| Installation URL | 6ink88ejqa440 |
|
| App Name | Jetpack | |
| Configuration | Release-Alpha | |
| Build Number | 34356 | |
| Version | PR #25965 | |
| Bundle ID | com.jetpack.alpha | |
| Commit | bcfdcd2 | |
| Installation URL | 2dooeo1d02dk8 |
🤖 Build Failure AnalysisThis build has failures. Claude has analyzed them - check the build annotations for details. |
dcalhoun
left a comment
There was a problem hiding this comment.
I smoke tested the prototype build on the PR, quickly testing text changes and image uploads/selection. Things appear stable.
I did not test local Xcode builds.
I left a few inline comments with findings raised by Claude that appear legitimate.
| # The React Native runtime loads the JS bundle from main.jsbundle at the app | ||
| # bundle root (copied in by the CopyGutenbergJS build phase). The identical | ||
| # copy inside Gutenberg.framework/App.js is never loaded at runtime and only | ||
| # adds ~7.7MB of dead weight to the embedded framework, so drop it. |
There was a problem hiding this comment.
Finding from Claude:
Since WordPress/Frameworks is gitignored, this strip persists across branch switches. Trunk's CopyGutenbergJS.sh gates on the framework directory existing and then unconditionally cps App.js, so after running rake dependencies here, switching back to trunk (or a release/* branch) fails every build with cp: .../Gutenberg.framework/App.js: No such file or directory until rake dependencies is rerun there. Worth a line in the PR notes alongside the existing "re-run rake dependencies" one, since the reverse direction isn't obvious.
| # bundle root (copied in by the CopyGutenbergJS build phase). The identical | ||
| # copy inside Gutenberg.framework/App.js is never loaded at runtime and only | ||
| # adds ~7.7MB of dead weight to the embedded framework, so drop it. | ||
| find "${FRAMEWORKS_DIR}/Gutenberg.xcframework" -path '*/Gutenberg.framework/App.js' -delete |
There was a problem hiding this comment.
Finding from Claude:
Previously "framework present" implied "bundle present". Now the source is a sibling folder that can be absent independently (e.g. a hand-copied *.xcframework from a local build.sh, which writes that folder beside Frameworks/, not inside it). In Debug the copy phase only warns, so the app ships with no main.jsbundle and the editor fails at runtime. Failing fast here keeps the invariant:
| find "${FRAMEWORKS_DIR}/Gutenberg.xcframework" -path '*/Gutenberg.framework/App.js' -delete | |
| [[ -f "${FRAMEWORKS_DIR}/react-native-bundle-source-map/main.jsbundle" ]] || { echo "error: expected react-native-bundle-source-map/main.jsbundle alongside Gutenberg.xcframework" >&2; exit 1; } | |
| find "${FRAMEWORKS_DIR}/Gutenberg.xcframework" -path '*/Gutenberg.framework/App.js' -delete |
|
|
||
| if [[ -d $XCFRAMEWORK_BUNDLE_ROOT ]]; then | ||
| cp "$XCFRAMEWORK_BUNDLE_ROOT/App.js" "$BUNDLE_FILE" | ||
| if [[ -f $DOWNLOADED_JS_BUNDLE ]]; then |
There was a problem hiding this comment.
Finding from Claude:
The warning:/error: messages in the else branch below (lines 28 and 30) still say the bundle wasn't found "in the XCFramework". The script no longer reads from it, and App.js is now absent from the framework by design, so the message sends someone to the wrong place. Naming the actual path helps:
echo "error: Could not find Gutenberg bundle at $DOWNLOADED_JS_BUNDLE."| @@ -1 +1 @@ | |||
| $SRCROOT/Frameworks/Gutenberg | |||
| $SRCROOT/Frameworks/react-native-bundle-source-map/main.jsbundle | |||
There was a problem hiding this comment.
Finding from Claude:
Pre-existing, but this change makes it load-bearing: the WordPress target's Copy Gutenberg JS phase (E1C5456F in project.pbxproj) lists this file under inputPaths rather than inputFileListPaths (Jetpack's FABB264C is correct), so Xcode tracks the xcfilelist file itself, not main.jsbundle. Latent today only because the declared assets/ output is never produced, which forces a rerun every build. Moving it to inputFileListPaths and dropping the empty outputPaths = ("") would be a cheap fix to fold in here.
Re-does the de-dup so it only affects Release builds and never mutates the gitignored on-disk XCFramework. Stripping App.js from WordPress/Frameworks during `rake dependencies` persisted across checkouts and broke every build on branches whose CopyGutenbergJS.sh still copies App.js from the framework (trunk, release/*) until dependencies were re-fetched. Instead: - Revert the three dependency/copy-phase scripts to trunk. - Add Scripts/BuildPhases/StripGutenbergAppJS.sh, which on *Release* only deletes the redundant App.js from the embedded Gutenberg.framework and re-signs it (CodeSignOnCopy seals the resource, so removal requires a re-sign). Runs as the final phase on the WordPress and Jetpack targets. Debug and simulator builds are untouched. Verified against the real framework: -7.7 MB, still passes codesign --verify --deep --strict.
The strip runs on build products and never touches the on-disk XCFramework, so the original Release-only gate (there to avoid changing dev builds) isn't needed — dropping the duplicate in Debug too gives smaller local installs and one less Debug/Release divergence. Replace the `*Release*` check with a safety check on the copy the runtime loads: only delete the framework's App.js once main.jsbundle exists at the app root, so we never remove the last copy (a Metro/dev-server Debug build that produced no root bundle keeps App.js). Verified in real Debug and Release Jetpack builds: App.js absent, main.jsbundle present, the app passes codesign --verify --deep --strict, on-disk Frameworks untouched.
|
@dcalhoun – these are some good findings, and I didn't come across them because I pretty much exclusively use worktrees. I reworked the PR so that nothing really changes during development, and instead we just strip out the duplicate JS at the last moment while building. |


Summary
Root Cause
The RN runtime loads the JS bundle via
RCTBundleURLProvider/jsBundleURLForBundleRoot:— i.e.main.jsbundlefrom the app bundle root, placed there by theCopy Gutenberg JSbuild phase (which copies it fromGutenberg.framework/App.js). Once that root copy exists, the embeddedGutenberg.framework/App.jsis never loaded at runtime — the framework binary references it zero times — so it is pure dead weight, embedded into (and code-signed with) the app.Fix
Strip the redundant
App.jsfrom the embedded framework in a build phase that works entirely on the build products, so the on-disk XCFramework (and the repo) is never mutated:Scripts/BuildPhases/StripGutenbergAppJS.sh: deleteApp.jsfrom the embeddedGutenberg.frameworkand re-sign it. Gutenberg is embedded withCodeSignOnCopy, so removing a sealed resource invalidates its signature — the re-sign restores it. The phase runs afterEmbed Frameworksand before the app's final code signing.main.jsbundleat the app root — is present, so it never deletes the last copy. A Debug build served from a Metro dev server that produced no root bundle keeps the framework'sApp.js.main.jsbundleand the framework copy.Readerships only the framework copy (no root fallback), so it is intentionally left untouched.What We Explored
An earlier revision stripped
App.jsfromWordPress/Frameworks/Gutenberg.xcframeworkduringrake dependencies. Because that directory is gitignored, the strip persisted across checkouts and broke every build on branches whoseCopy Gutenberg JSphase still copiesApp.jsfrom the framework (trunk,release/*) withcp: …/App.js: No such file or directoryuntilrake dependencieswas re-run there. The build-phase strip avoids mutating shared on-disk state entirely.Test Plan
App.jsis gone from the framework, so a working editor proves the rootmain.jsbundleis what the runtime loads..ipano longer containsGutenberg.framework/App.jsand installs cleanly — confirm on the CI prototype build.Gutenberg.framework/App.jsabsent, rootmain.jsbundlepresent (7.7 MB), the whole.apppassescodesign --verify --deep --strict, and on-diskWordPress/Frameworksis unchanged.Gutenberg.framework: −7.7 MB, passescodesign --verify --deep --strict.bash -n; gating unit-tested (strips in Debug and Release, preservesApp.jswhen no root bundle, no-op when already stripped).Notes
The RN editor is being replaced by GutenbergKit; this doesn't touch that migration, it just stops shipping the RN bundle twice in the meantime.