Skip to content

fix: box transition enums so Component.Content stays small (stack overflow on deep screens)#33

Open
aliksbright wants to merge 1 commit into
mainfrom
fix-crash
Open

fix: box transition enums so Component.Content stays small (stack overflow on deep screens)#33
aliksbright wants to merge 1 commit into
mainfrom
fix-crash

Conversation

@aliksbright

Copy link
Copy Markdown
Contributor

Summary

Marks ScreenTransition and ComponentTransition as indirect. This shrinks every component's DSL.Model.Component.Content value from ~5.2 KB to ~1.3 KB, fixing a main-thread stack overflow (EXC_BAD_ACCESS) that occurred while rendering deep screens.

Symptom

Navigating to the onboarding paywall (onboarding-04: a scrollView with two nested template PlanCards) crashed with EXC_BAD_ACCESS (code=2) at a stack address, with __chkstk_darwin + initializeWithCopy for DSL.Model.Component.Content on the stack. It looked device-specific because it's sensitive to timing and stack depth, but it reproduces deterministically.

It was not recursion (render depth peaked at 4) and not a cache/DSL problem (the cached DSL was clean and identical across devices).

Root cause

Component.Content stores the transition enums inline by value, and those enums are large:

inline field bytes
NavigationBar 2968
ComponentTransition (transition) 994
everything else ~1250

ScreenTransition.Inline embeds many sub-models (Participant ×2, Animation, ModalPresentation, InteractiveConfig, …) by value (~1 KB). It's inlined into Content in several places:

  • Content.transitionComponentTransitionScreenTransition
  • Content.navigationBarNavigationBarleftAction/rightAction (BarAction) → Action.transitionScreenTransitiontwice per component

So NavigationBar alone was ~3 KB, carried on every component's Content (even simple labels) even though only screen roots use a nav bar or a screen transition.

Every component's view model copies its whole Content by value on init (content as? Component, self.component = c, ComponentStateManager(content:), state binding). In debug (-Onone) builds these copies aren't optimized away, so a moderately deep screen accumulates enough 5 KB stack copies to exhaust the 1 MB main-thread stack mid-render.

Fix

indirect on ScreenTransition and ComponentTransition moves each enum's payload to the heap, making the field pointer-sized. This removes the ~1 KB transition field and collapses NavigationBar (its bulk was the two inlined Action.transition values), so Content drops to ~1.3 KB. Copies become cheap and the overflow is gone in debug and release.

indirect changes only memory layout — no API, decoding, or behavior change.

Verification

Reproduced on device, instrumented MemoryLayout to measure Content and each inline field, confirmed the 5.2 KB / 2968 B NavBar / 994 B transition breakdown, applied the change, and confirmed the paywall renders (Content measured ~1.3 KB, no crash). Instrumentation was removed after diagnosis.

Follow-up

  • Cut a new engine version and bump the App8Cloud pin to pick this up.
  • Optional hardening (separate PR): the recursion backstop in renderComponentImpl counts path dots, not call depth — worth making call-depth-based so any future runaway degrades to an error view instead of crashing.

🤖 Generated with Claude Code

Mark `ScreenTransition` and `ComponentTransition` as `indirect`.

Root cause: `DSL.Model.Component.Content` was ~5.2 KB per value because the
transition enums are stored inline. `ScreenTransition.Inline` embeds several
sub-models by value (~1 KB) and is inlined into `Content` in multiple places —
`Content.transition` (`ComponentTransition`), and `Action.transition`, which
appears twice inside every `NavigationBar` via its left/right `BarAction`s.
`NavigationBar` is itself inlined into `Content`, so `navigationBar` alone was
2968 B and `transition` 994 B of the 5.2 KB — on every component, even simple
labels, and even though only screen roots use them.

Every component's view model copies its whole `Content` by value on init
(`content as? Component`, `self.component = c`, `ComponentStateManager(content:)`,
state binding). In debug (`-Onone`) builds these copies aren't elided, so a
moderately deep screen accumulates enough 5 KB stack copies to overflow the
1 MB main-thread stack mid-render — surfacing as `EXC_BAD_ACCESS` (code=2) at a
stack address with `__chkstk_darwin` / `initializeWithCopy` on the stack. It
reproduced on the onboarding paywall (scrollView + nested template cards), and
looked device-specific only because it's timing/stack-depth sensitive.

`indirect` moves each enum's payload to the heap, so the field becomes
pointer-sized. `Content` drops from ~5.2 KB to ~1.3 KB (measured), copies become
cheap, and the overflow is gone in both debug and release. No API or decoding
change — `indirect` only affects memory layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant