Skip to content

Main thread spins in a SwiftUI layout/update loop while typing in the row inspector's JSON field editor #3051

Description

@YunKang-Chen

Summary

Editing a value inside a MySQL json column in the row inspector's JSON field editor makes the app
unresponsive. In one session macOS produced three reports about 2 minutes apart: a CPU report (90 s CPU
over 150 s) and two hang reports (135.74 s and 71.81 s unresponsive). All three samples show the same
main-thread cycle, and the app recovers once it unwinds.

The document is only ~1.6 KB (~40 lines pretty-printed), so this is not a size or parse-latency
issue — it is a layout/update feedback loop that never converges.

Version: 0.75.0 (131) · macOS 15.7.9 (24G830) · arm64 · Team ID D7HJ5TFYCU

Steps to reproduce

  1. Open a connection to a MySQL database with a table that has a json column.
  2. Select a row, open the row inspector, use the JSON field editor.
  3. Type a character inside the JSON.

Result: the main thread spins; the window reports "not responding" for 1-2 minutes before recovering.
It reproduced on every attempt in that session.

Expected behavior

Typing in the JSON field editor updates the value without entering a layout loop. The editor should not
write observable state from within the SwiftUI update pass, and the displayed form of the value should
round-trip through the binding without re-parsing the document on every keystroke.

Main-thread stack (sanitized excerpts from the three reports)

1. layout/update → the embedded editor's representable update → scroll → bounds notification → hidden-state change

closure #5 in closure #1 in PlatformViewChild.updateValue()
static Update.ensure<A>(_:)
partial apply for closure #1 in perform #1 <A>(work:) in closure #5 in closure #1 in PlatformViewChild.updateValue()
ViewRendererHost.performExternalUpdate(_:)
closure #1 in closure #1 in closure #5 in closure #1 in PlatformViewChild.updateValue()
PlatformViewRepresentableAdaptor.updateViewProvider(_:context:)
??? (TablePro + 29326276)
??? (TablePro + 29325320)
??? (TablePro + 29313988)
??? (TablePro + 29327168)
??? (TablePro + 29334372)
??? (TablePro + 28995552)
-[NSView scrollPoint:]
-[NSClipView _scrollPoint:fromView:]
-[NSClipView _scrollTo:animateScroll:flashScrollerKnobs:]
-[NSScrollView scrollClipView:toPoint:]
-[NSClipView scrollToPoint:]
-[NSClipView _immediateScrollToPoint:]
-[NSView translateOriginToPoint:]
-[NSView _postBoundsChangeNotification]
-[NSNotificationCenter postNotificationName:object:userInfo:]
_CFXNotificationPost
_CFXRegistrationPost
___CFXRegistrationPost_block_invoke
__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__
??? (TablePro + 26618164)          <- app observer on the clip view's bounds change
??? (TablePro + 30896648)
??? (TablePro + 30859636)
??? (TablePro + 30674000)
??? (TablePro + 30949460)
??? (TablePro + 30948724)
-[NSView _setHidden:setNeedsDisplay:]
-[NSView _recursiveGainedHiddenAncestorBySwitchingWindow:]
-[NSView didChangeValueForKey:]

2. A SwiftUI onChange handler writes state during the render pass (same sample)

ViewRendererHost.render(interval:updateDisplayList:targetTimestamp:)
static Update.dispatchActions()
Update.Action.callAsFunction()
partial apply for closure #2 in ValueActionDispatcher.updateValue()
_ValueActionModifier.sendAction(old:)          <- SwiftUI onChange
??? (TablePro + 12177520)
??? (TablePro + 12174004)
??? (TablePro + 12173808)
Binding.wrappedValue.setter
LocationBox.set(_:transaction:)
FunctionalLocation.set(_:transaction:)
partial apply for closure #1 in Binding.init(get:set:)
??? (TablePro + 11549680)
??? (TablePro + 17285000)
static Published.subscript.modify
static Published.withMutation<A>(of:keyPath:storage:apply:)
closure #1 in static Published.subscript.setter
PublishedSubject.send(_:)
ObservableObjectPublisher.send()
ObservableObjectPublisher.Inner.send()
SubscriptionLifetime.Connection.receive(_:)
AttributeInvalidatingSubscriber<A>.receive(_:)
AttributeInvalidatingSubscriber.invalidateAttribute()   (running)

3. The other hang report: the same representable update pass, inside text layout and frame adjustment

PlatformViewRepresentableAdaptor.updateViewProvider(_:context:)
??? (TablePro + 29326276)
??? (TablePro + 29325092)
??? (TablePro + 29338748)
??? (TablePro + 29313988)
??? (TablePro + 29338848)
??? (TablePro + 29000044)
??? (TablePro + 30893076)
??? (TablePro + 30893888)
??? (TablePro + 30698436)
??? (TablePro + 30694776)
??? (TablePro + 30859932)
-[NSView setFrame:]
-[NSView setFrameSize:]

The binary is stripped (the only defined symbol is __mh_execute_header), so these offsets cannot be
resolved to function names from the reports.

Suspected loop in the 0.75.0 source

  1. TablePro/Views/RowInspector/FieldEditors/JsonEditorView.swift:35-64 — both onChange handlers
    (propagateEdit(), syncFromBinding()) write context.value / displayText from inside the update
    pass, which is what the _ValueActionModifier.sendAction excerpt above shows.
  2. TablePro/Models/UI/MultiRowEditState.swift:304resolvePendingValue returns
    JsonReindenter.normalize(value) for isJson fields, so the binding stores compact JSON while the
    editor displays pretty JSON (reindent). The two sides are only ever compared equal after a full
    re-parse of the document, in both onChange handlers, on every keystroke.
  3. Packages/TableProEditor/Sources/TableProEditorKit/SourceEditor/SourceEditor.swift:154
    updateNSViewController pushes text and the recorded scroll position back into the TextKit view on
    every SwiftUI update (applyRepresentableText, and controller.scrollPosition = ..., which scrolls
    for real: Controller/TextViewController.swift:189). The resulting clip-view bounds notification
    re-runs gutter and floating-inset layout (Controller/TextViewController+Lifecycle.swift:94-116,
    Controller/TextViewController+StyleViews.swift:82, Gutter/GutterView.swift:347), which invalidates
    layout again, closing the cycle.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions