Verified constraints and pitfalls

Things that were found the hard way. Read the section of the area you change before changing it.

QuickJS and the runtime

  • QuickJS microtasks are run by the host. Call Host::run_jobs() after every call from Rust into JS. setTimeout / console are our own; queueMicrotask, WeakRef and FinalizationRegistry are built into QuickJS-NG.
  • Host::run_jobs() is also the end of the turn for unhandled rejections (rejections.rs): QuickJS‘s rejection tracker reports a promise rejected without a handler and one that gets a handler later; the host holds the first kind until the job queue is drained, then reports those still unhandled (Host::set_rejection_handler, else a js error log). A handler attached later in the same turn makes no report, as in browsers. A promise the host settles or reads natively without then (the bundle’s evaluation promise, a stream's) is marked handled (JS_PromiseMarkAsHandled), or it is reported. Interrupts are uncatchable and reject nothing; rejections while the heap ran out are dropped, the VM stops with that report.
  • The JS object graph must stay reachable from a GC root. The renderer holds the root node, which holds its children‘s objects and each node its handlers (dom.rs, reported by the classes’ GC marks). Without that the whole rendered app (nodes, handlers, signals) is an isolated cycle that idle GC collects, and taps stop working. nsh test App.tsx runs the steps of stdin after 1.2s of idling for that; crates/nsh-engine/tests/engine/counter.rs is the regression test.
  • Nodes are released by GC, not by Remove. A memo may keep removed nodes and insert them again, so Remove only detaches; Release (queued by the node's finalizer) frees the node and its native view.
  • nsh_js::BUNDLE_NAME (bundle.js) is the module name of every bundle and is how stacks are matched for symbolication; BYTECODE_VERSION names the QuickJS-NG release and revision, BC_VERSION and the byte order, never the binding. Native and browser targets use quickjs-jit (the browser uses its interpreter and pregenerated bindings) and must build the same QuickJS-NG, as the web runtime loads what the native CLI compiles. Tests hold it to the linked release, the bytecode's format byte and the -sys versions in Cargo.lock (ENGINES): update them together when upgrading either.
  • Encoded operations' codes (ANIMATION_*, CV_*) come from nsh_core::js (protocol.rs, canvas.rs): never write a code's value by hand in nsh-js.
  • rquickjs converts Option::None to undefined, not null: compare native results with == null in JS.
  • on* props are event handlers to the renderer (held on the node, sent to the core as a listener flag): settings must not be named on... on host elements (List sends loadMoreThreshold as loadMoreThreshold).
  • There is no error boundary: hot component proxies catch what a component throws, report it and render nothing until the fix (nsh-js hot.rs).
  • Hot proxies return accessors, not nodes: runtime code that inspects what a component returned (NavStack looks for a <NavPage>, navigation.rs) must accept an accessor.

Core and layout

  • Text nodes outside <Text> (e.g. a string child of a <View>) create no native view and take no part in layout. A <Text> nested in <Text> is a span without a native view; the whole block is merged into one AttributedText.
  • TextSpan offsets are UTF-16, like NSAttributedString / Android Spannable.
  • Never re-insert a native child that is already in place. Insert of a mounted view is a move: the platform takes it out of the window for a moment, which ends its touches and its focus (an Android input loses the keyboard). Child diffs (Rust components' reconcile_children) move only the children out of order, from the end, with before anchors.
  • Taffy 0.15 custom measure callbacks must delegate to taffy::compute_leaf_layout and only compute the content size. Returning the content size directly skips resolving the leaf's own style (width / height / padding / min / max), so leaves such as <Image style={{width: 48}}> end up with a zero size.
  • Taffy 0.15's rounding pass is off; the core rounds layouts itself (rounded, Core::layout_origin, the frame walk). Taffy rounds absolutely positioned nodes through their containing block‘s list of “hoisted” children, which it leaves stale: a childless node keeps the absolute child it last laid out, a hidden subtree’s are never filled, a containing block served from the cache keeps its old ones. A freed node still listed made the next pass panic (invalid SlotMap key), a hidden one kept its former layout, and an incremental layout could round a node from stale values (a frame one point off from a layout from scratch). The core reaches every node through its parent, its containing block (there is no position: static), and sums the origins from the root down, in Taffy‘s order, so the f32 sums and the rounding are Taffy’s to the bit (--trace output is unchanged).
  • Taffy 0.15's mark_dirty stops at the first node whose cache is already empty, taking its ancestors for dirty too. Nodes of a display: none subtree always have empty caches while the hidden node above keeps its result, so a view moved in there keeps its former layout: the frame walk gives everything under a display: none view an empty frame and its texts an empty content box, whatever Taffy keeps for them (emit_frames_in, flush_text).
  • List cells are independent Taffy roots (not children of the list‘s Taffy node; their width follows the list’s cross axis): scrolling only relays out dirty cells and only emits frames for relaid cell subtrees; when the main tree is clean, compute_layout and the frame walk of the main tree are skipped.
  • Taffy defaults are CSS semantics; Style::default() switches to RN semantics: column, flexShrink 0, alignContent flex-start (stored as normal and resolved per display by style::resolve_normal_alignment, so grids keep CSS‘s normal), position relative, and min sizes 0 (an unset or auto minWidth / minHeight is 0). Taffy’s auto minimum is CSS's automatic minimum size (the min-content size); text has no cheap min-content and reports its natural width, so flex: 1 text in a row could not shrink and overflowed instead of wrapping. Yoga has no automatic minimum.
  • MountItem::Insert positions by sibling (before), not by index: platforms insert their own subviews into containers (UIRefreshControl at the bottom, scroll indicators), which would shift indices.
  • Platforms clamp scroll offsets to the content: UIKit clamps contentOffset when contentSize shrinks, Android‘s ScrollView clamps scrollBy to the child’s current size. The core grows the content before a ScrollBy and scrolls before shrinking it; Android sizes the content view at once in setContentSize (not at the next layout pass).
  • Scroll reports can predate the core's own scrolls: each report carries how many ScrollBy / list ScrollTo the view has applied (EngineControl::scroll(.., applied)), and the core adds the ones still pending; otherwise a stale offset moves the window (and fires onLoadMore) backwards.
  • Unbound list cells still show their last item: the core moves them out of sight (never with a NaN frame: cells that were never placed are skipped; UIKit throws on NaN geometry).
  • Tailwind variant order decides between variants, not their order in the string: odd:bg-a last:bg-b gives an odd last row bg-a (Tailwind sorts odd after last), exactly as in CSS.

Bundler and toolchain

  • rquickjs must not enable the full feature. It pulls in dyn-load (dlopen2), which does not compile for iOS / Catalyst. iOS targets have no prebuilt bindings and need the bindgen feature (and libclang).
  • Use oxc_sourcemap 9.x (matching rolldown / oxc 0.153): source maps are composed with rolldown‘s types, so never mix in another version’s.
  • Our plugin's own compile errors are kept in NshPlugin.error and shown directly (clean code frame + relative path) instead of rolldown's wrapped report.
  • rolldown CommonJS chunks define reassigned exports as non-configurable getters: a module run again fills a fresh exports object and the hot runtime forwards the importers' object to it.
  • nsh run installs the host by copying to a temporary file and renaming it: overwriting in place an executable that another session is running makes the kernel SIGKILL the newly launched process for a code signature mismatch.
  • Development hosts never restore windows: nsh run launches the Catalyst host with -ApplePersistenceIgnoreState YES; after a crash, macOS would otherwise show a modal “reopen windows?” alert over it.
  • IPAs are zipped with ditto -c -k --norsrc --keepParent: --sequesterRsrc adds __MACOSX/ resource forks.

iOS, UIKit and Mac Catalyst

  • Pressed-style observers must leave ancestor clicks working: a view with only activeStyle / active: has a PressRecognizer to report its pressed state, but yields the click to the nearest ancestor with press handlers. handled_deeper counts handlers and pressable text spans; a style-only recognizer ends as failed after reporting its state, so it does not compete with the ancestor's recognizer.

  • UIFont and CTFont are toll-free bridged: the text engine gets system fonts with UIFont::systemFontOfSize_weight and uses them as CTFonts (verified by rendering). CoreText attributes must use CT keys (colors are CGColors), not UIKit's NSForegroundColor.

  • Catalyst's Mac idiom does not support UIRefreshControl (attaching one throws _UICatalystUnsupportedMacIdiomBehavior): in the Mac idiom the pull is detected in the scroll delegate and shown with a spinner + contentInset; iOS uses the native control.

  • Mac idiom custom pull to refresh: on release, the system's bounce already targets the old inset (back to 0), so changing only contentInset hides the spinner; once refreshing starts, the offset is animated to -REFRESH_INSET on the next main-queue turn. Synthetic tests need continuous scroll events with phases (began / changed / ended); plain wheel events never overscroll.

  • Set backgrounds on layer.backgroundColor, not UIView.backgroundColor: views with drawRect: (TextView) paint the latter into their contents bitmap, where corners cannot round it.

  • Styles arrive before frames (new views have a zero size): a shared radius goes straight to the layer (Core Animation clamps it itself); only paths, shadow paths, the content view and the image layer depend on the size, and they are rebuilt on Frame while Decor::sized() is true. Scaling radii by the CSS rule at a zero size once turned every rounded corner square.

  • Property changes on standalone CALayers animate implicitly for 0.25s (a UIView's own layer does not): wrap changes to shape / image layers in CATransaction.setDisableActions(true).

  • Core Animation always draws border above sublayers, while CSS borders are below children: non-clipping views use a stroke layer below the children; when clipping both look the same, so the layer's own border is used.

  • A UIScrollView's sublayers scroll with its content (bounds.origin is the scroll offset), so scroll views cannot use shape layers / masks.

  • UIKit callbacks may fire synchronously while the Mounter applies a batch (e.g. setting contentOffset triggers scrollViewDidScroll): when EventTarget::with_mounter cannot borrow, it defers to the next main-queue turn; never borrow_mut directly.

  • Views with a transform must not set frame: always use bounds + center, keeping the bounds origin (a scroll view's bounds origin is its scroll offset).

  • Catalyst: UIDeviceFamily must include 6 (Mac idiom), otherwise the UI is scaled to 77%. Under Catalyst the container extends below the title bar, so overlays are constrained to safeAreaLayoutGuide. UIDevice reports “iPad” under Catalyst. Use the UIScene lifecycle (UIScreen.mainScreen and UIWindow initWithFrame are deprecated). The window size is fixed to 390×844 through sizeRestrictions.

  • Methods in define_class! that return Retained<T> need #[unsafe(method_id(...))]. Classes UIKit instantiates by name (AppDelegate, SceneDelegate) cannot have ivars; keep their state in statics or thread locals.

  • UIKit implements the UITextInputTraits setters of UITextField by message forwarding: objc2's debug-build signature check cannot find them on the class and panics, so input.rs sends them with a raw objc_msgSend.

  • objc2 crates are used with default-features = false and an explicit feature list in the root Cargo.toml (one feature per header): the defaults link CloudKit, CoreData, CoreImage, CoreLocation, Metal, ... into every app (each one costs dyld time at launch). When a method is missing after using a new API, enable the header feature named in its #[cfg(feature = ...)]; check otool -L on a release nsh-ios-app afterwards (only UIKit, Foundation, CoreFoundation, CoreGraphics, CoreText, QuartzCore, ImageIO).

  • TextView uses UIViewContentMode::Redraw: list cells can get their text before their frame; without redrawing on resize the text stays drawn at zero size.

  • UINavigationController breaks transitions started during another one: stack changes that arrive while transitionCoordinator() is set wait for didShow and are applied on the next main-queue turn (the coordinator outlives the callback); only pages the controller actually had count as popped by the user.

  • UIAlertController asserts when its presentation controller's delegate is set; a popover action sheet (iPad, Mac) hides its cancel action and runs it on a tap outside, so sheets without one get a hidden, untitled cancel answering “dismissed” (null).

  • Catalyst: a mouse drag does not scroll a UIScrollView (Mac behavior); test scrolling with trackpad-style scroll wheel events (with phases), which also produce drag and momentum callbacks. Mouse drags do reach views as touches.

  • Catalyst: the Mac idiom moves a navigation bar‘s contents into the window’s toolbar (UINavigationBar.behavioralStyle resolves to .mac; macOS 26 does it even with the scene‘s titlebar.toolbar nil): the back button sits next to the traffic lights, the title is gone (or is the window’s) and the header‘s colors do not apply. Stacks’ bars (and the bar measuring the header height) set preferredBehavioralStyle = .pad (iOS 16+) to stay in the window, and standalone apps hide the window‘s title and toolbar (UITitlebar, which objc2 does not declare: app.rs sends its setters by name), so the title bar is a transparent strip over the in-window header, like iOS’s status bar; apps embedding NSH on Catalyst should hide theirs the same way. The core‘s estimate of a header (the bar’s height plus the safe area‘s top) can still miss what UIKit lays out, which showed the screen’s root view in a band under the bar: screens report the top of their controller view‘s safe area (the bottom of whatever bar UIKit shows) as their header (Core::set_screen_header), and the stack’s bar gets the screen's appearance as well as its navigation item.

  • Catalyst's Mac idiom presents page / form sheets as macOS sheets at a default size wider than a 390pt window: they get the window's size as preferredContentSize. Standalone apps have a white root view (UIKit windows are black).

  • iOS clamps shared corner radii itself (rounded-full is 9999): Core Animation does not reliably clamp a cornerRadius over half the short side, so decor.rs clamps it and clamps again when the size changes.

  • iOS, dismissing a modal that presents another: dismissViewControllerAnimated called on a controller that is itself presenting one (a Sheet is a transparent modal over the modal it is in) dismisses only that one, and the outer modal stays on screen with its content already gone: a blank screen when a sheet and its modal close together. ModalHost::dismiss dismisses through presentingViewController, which takes the modal and everything above it.

  • iOS, presses near the top of the window: UIKit attaches tap recognizers to the window that skip UIControls, and a press is a gesture recognizer, not a control. On iOS 26+ the window‘s _UIDoubleTapInteraction (a single-tap recognizer over the top 64 pt, cancelsTouchesInView, never simultaneous, action disabled) recognized first and prevented every press there, so a navigation bar’s back button did nothing (seen on an iPhone foldable simulator; a real tap through XCUITest reproduced it). PressRecognizer is not preventable by tap recognizers on a UIWindow (press.rs, is_window_tap); edge-gesture gates are not taps and still cancel presses.

  • iOS, the screen scale: a UIView that draws nothing itself keeps a contentScaleFactor of 1 on a 3x screen, and a drawn one raises it while zoomed, so neither is the screen‘s scale. Images decoded at it came out at a third of their pixels and blurred; the scale is the trait collection’s displayScale (Mounter::display_scale).

Android

  • Android, JNI: cache classes and method ids in JNI_OnLoad; wrap every call from the (permanently attached) engine thread in a local frame (java::with_env), or local references pile up.
  • Android, text: StaticLayout ignores maxLines without an ellipsis (clip mode counts lines itself and the view clips); build layouts from PrecomputedText with the same break strategy / hyphenation / text direction as its params, or it measures again. A layout of precomputed text does not draw UnderlineSpan / StrikethroughSpan (even ones set after create): runs carry a TextLayouts.Decoration marker and TextBox draws the lines. Layout.Alignment is relative to each paragraph's direction: absolute left / right alignment sets an AlignmentSpan per paragraph from its resolved direction.
  • Android, JNI event maps intern their keys from a fixed list (Java_com_longbridge_nsh_Native_eventMap): a new payload key must be added there or it is dropped silently.
  • Android, inputs: EditText reconfiguration (input type, filters, single line) sets the same text again and fires afterTextChanged: only text that differs from the last known one counts as an edit, or JS values are rejected as stale. Soft keyboards edit through the input connection (KeyConnection: composing text, commits, deletes), hardware keys arrive as key events; both feed onKeyPress. A hardware Enter reports its down and its up — act on the down only and consume the up, or the up advances focus again in the next field; blur by focusing the (focusable, highlight-free) NshView, as clearFocus() hands focus to the first focusable view; set the typeface after the input type (password types reset it to monospace). The composition (onCompositionChange) is read from the composing span (BaseInputConnection.getComposingSpanStart / End) after every edit, connection call and batch end, since keyboards start and end it in many ways (setComposingRegion included); a value JS sets meanwhile waits for its end (setText restarts the keyboard, which commits the pre-edit text), and is never applied from inside the text watcher.
  • Android, keyboard avoidance: the keyboard‘s height becomes bottom padding of the scroll around the focused input, and ScrollBox.reveal scrolls it into view; requestRectangleOnScreen cannot, as ScrollView ignores its padding when it decides whether a rectangle is visible. NshView is a scroll container (setScrollContainer(true)) so that windows without a soft input mode resize instead of panning: the system picks the mode at the first layout, before the app’s content is mounted, and a mode set from code afterwards is overridden. Windows that ask for adjustPan are left to the system.
  • Android, passive press feedback: activeStyle / active: and android.ripple observe a touch without consuming it. The root‘s TouchTracker feeds style-only views through Pressable; Box and leaf views take clicks for their press handlers or text spans. Feedback is cancelled when scrolling, panning, pinching or rotation takes over, so a styled child leaves its parent’s overlay or menu trigger clickable. The walk up from the touched view stops at the first press handler, or at a text only when the finger is on one of its pressable spans (as iOS's handled_deeper checks the link at the touch), so a card around plain text still shows its pressed style. While feedback shows, the root claims the gesture as a clickable view would: Android sends the release only to the view that took the down, and the release is what ends the feedback.
  • Android, drags that start on a child: a pannable Box intercepts the move that exceeds the slop and starts its pan right there; that intercepted move never reaches its onTouchEvent, so starting the pan there is what makes a quick flick (few moves) pan and end at all.
  • Android, Dialog content placement: Box.place lays a view out at its core frame directly, so the status bar offset of a non-translucent Dialog (no statusBarTranslucent) is a content view inside the Android dialog‘s container, not an offset applied to the root’s layout; the dialog copies the activity's bar appearance (light / dark icons).
  • Android, nested scrolling: scroll views enable nested scrolling, or an outer vertical ScrollView intercepts every vertical drag meant for an inner one.
  • Android, safe area: NshView reports only the parts of the system bars / keyboard that overlap it (like UIKit's safeAreaInsets), so embedded views below a toolbar get no top inset. Overlays use the same overlap (topInset()).
  • Android, dev assets come over plain HTTP, which apps block by default (API 28+): the dev host APK and nsh-dev.aar set usesCleartextTraffic; the release AAR does not.
  • Android, activities: create the decor view (getDecorView()) before asking for the insets controller; apps draw edge to edge and get the system bars through safeArea().
  • Android, the back key without OnBackInvokedCallback (below 13, or enableOnBackInvokedCallback off): the window sends KEYCODE_BACK down the focus path, then the activity's onKeyDown / onKeyUp take it (onBackPressed), so an unfocused view only hears it through View.addOnUnhandledKeyEventListener (API 28+, called by the decor view between the two, whatever has focus). With the callback in effect the window turns the key into the callback before any view, so NshView taking the key never doubles its callback (seen on Android 16, both settings).
  • Android widgets: MediaPlayer.setPlaybackParams starts playback (set the rate only while playing) and a paused player draws nothing until it seeks; WebView JavaScript bridge calls arrive on a binder thread (post to the UI thread).
  • Android, negative letter spacing: Minikin adds half the spacing on both sides of each glyph and trims the halves at line edges, so a line is wider than its sum of advances; TextLayouts.Prepared.desired adds the largest negative spacing back, or text laid out at its natural width wraps (tracking-tight).

Linux, GTK

  • inotify reports reads (Access events), FSEvents does not: file watchers (nsh dev, cargo run‘s xtask) ignore Access events, or the build’s own reads of the sources start the next build, forever. inotify also reports paths under the watched one as given: watch a canonical path (a .. in it makes every path look like a dot entry).
  • GDK frame times are microseconds (FrameClock::frame_time); nsh_anim::Graph::tick takes seconds and the scroll and transition code milliseconds: convert at each call site.
  • A widget's accessible role is effectively fixed once its AT context is realized: GTK refuses to change it (with a critical), and nothing tells whether it is. Roles are decided when the view is created, from the batch that creates it (access::prepare); a later change detaches the view and puts it back.
  • gtk_snapshot_append_repeating_radial_gradient does not repeat on GTK 4.22: append a gsk::RepeatingRadialGradientNode made with its constructor.
  • Bitmap fonts (color emoji) describe their strike's size: a run's font description from describe_with_absolute_size names the bitmap size, not the size asked for; set the absolute size again before the main thread loads the font by description.
  • GDK decodes only PNG, JPEG and TIFF by itself; GIF and WebP need gdk-pixbuf loaders many systems lack, so the image crate decodes them (their first frame).
  • GTK 4.22 damages a translation with a whole dx and a fractional dy wrongly: gsk_transform_node_diff tests floorf (dy) != dy (fixed upstream to ==) and translates the damage by the truncated offsets, so the pixels of what moved or shrank under a fractionally scrolled view stay on screen. view.rs places children at whole points and moves a remaining translation off that path (placement). Check damage on screen (import -window root, grim), not with shot, which renders a fresh frame; GSK_DEBUG=full-redraw tells a damage bug from a drawing one.
  • GSK‘s cairo renderer paints text outside its node’s bounds: a text node is bounded by the unhinted ink, cairo draws the glyphs hinted with the baseline on the pixel grid, a pixel or so beyond. A view with text (or a text field) adds a clear node TEXT_OVERHANG around its bounds that changes with each NshView::changed, so its damage covers that (ViewState::draw_overhang).
  • WebKitGTK hands back keys a page did not use, except arrows: PageClientImpl::doneWithKeyEvent puts unused key presses back into GTK (so the window's bubble-phase controllers see Escape), but always keeps arrow keys, so Alt+Left never reaches the window from a focused page. native/web.rs takes the history keys in a capture-phase controller on the web view.
  • WebKitGTK's decide-policy does not say which frame a navigation is for (WebKitNavigationAction has no main frame flag, and subframe navigations come through it too): a policy decided there applies to frames as well.
  • GTK delivers pointer events by allocation, ignoring NSH‘s clipping (overflow: hidden in snapshot), hidden and pointerEvents: a hosted widget (a web view’s page) gets clicks and wheels outside what is drawn of it unless the root's capture controller stops them (input.rs's shields_page).
  • Tiling window managers ignore the host's --window-size (the window‘s default size): a test’s screenshots have the size the window got, and so does app.setViewport (it resizes on X11 and floating windows).

iOS, the test agent's input

  • Touches made in process do not move the first responder: a made-up tap on a text field leaves the keyboard's focus where it was, so the agent focuses the input a tap lands in itself (as KIF does).
  • UITextField.insertText: skips the field's delegate (no shouldChangeCharactersInRange, so no maxLength): typing goes through UIKeyboardImpl, which asks it, as the software keyboard does. Keys typed faster than a controlled input‘s value comes back lose characters, so keys come at a typist’s pace.
  • A finger at rest still reports its position: a drag that stops sending events before it lifts keeps its last velocity and flings. A drag whose path leaves the scroll view moves it much further than the finger.
  • Made-up edge swipes do not start interactivePopGestureRecognizer; the back button is tapped instead. A scroll view's glide or bounce is not a Core Animation animation: isDecelerating says it moves, and a tap during it only stops it.
  • A menu's backdrop covers the header: on a touch screen a menu is a sheet over a backdrop on the whole window, so a tap at the header‘s back button lands on the backdrop and closes the menu (backdropPress) instead of going back from a submenu. iOS has no back key, so while the app listens to going back the agent’s back is VoiceOver's escape gesture on the surface (RootView's accessibilityPerformEscape), which the core takes as the back button.
  • -[UIApplication _enqueueHIDEvent:] takes an IOHIDEventRef: objc2's debug type checks reject an object pointer, so the argument has the ^{__IOHIDEvent=} encoding.

Android, the test agent's input

  • Ctrl+key presses are shortcuts first: the window's input stage (ViewRootImpl) offers them as dispatchKeyShortcutEvent before the key event, and a TextView selects all only there; key events dispatched to the views directly skip it, so the agent offers them as shortcuts itself.
  • A view's motion is not observable as one value: scroll views fling with a private OverScroller and views animate with ViewPropertyAnimators, so the agent compares a signature of every shown view's place, offset, scroll position and opacity between looks.

macOS, AppKit

  • Closing a window synchronously calls its delegate, which stops the surface and clears its mounter. Commands applied while the mounter is borrowed defer window closure to the next main-queue turn (system.rs, ExitApp), so teardown can borrow the mounter again.

  • Changing FullSizeContentView can leave the content at its old size after setStyleMask: returns: a hidden title bar then leaves a strip of window background until the next resize. chrome::apply_title_bar reconciles the content view‘s size with contentRectForFrameRect: on the next main-queue turn, using the window’s current frame and style (including any later title bar change in the same batch).

  • A layer-backed NSView‘s layer belongs to AppKit: moving or resizing the view sets its layer’s anchor point, position and transform again. NshView::place sets the frame first, then anchor (top left), position, transform and opacity, every time; a transform set elsewhere is lost on the next move.

  • Layer contents sit below every sublayer of the view: a fill sublayer (differing corners, gradients) would hide content drawn into the view's own layer, so text and canvases draw into a content sublayer (zPosition between the decoration behind the view and its children).

  • setBoundsOrigin: (scrolling) marks every drawRect: view below it for display (NS_viewVisibleRectDidChange), clipped or not, on every scroll step; plain sublayers drawn by a delegate are not affected (view.rs).

  • Trackpad scroll events come at about the display's rate but not in step with it: applied as they come, frames move twice or not at all while the frame rate reads 120. Resample them at display frames by their timestamps (scroll.rs).

  • NSEvent.timestamp is on CACurrentMediaTime's clock (systemUptime); a CGEvent made without a source has timestamp 0 until set (nanoseconds). NSWindow.sendEvent: drops synthesized momentum-phase scroll events: scripts hand them to the view under the pointer.

  • Clipping a layer (masksToBounds, a mask) clips its shadow too: a view that clips its children and casts a shadow moves the children into a container that clips, and keeps the shadow on its own layer.

  • NSView's display link does not fire while the window is occluded or minimized, or the screen is locked: a surface that wants frames falls back to a main-queue timer after 100 ms without one (surface.rs), or transitions never end.

  • window.sendEvent: delivers an event without making it NSApp.currentEvent, and synthesized mouse moves only arrive when the window accepts mouse moved events; a window-less CGEvent‘s location is in screen coordinates. The test agent’s input posts to the application's queue (synth.rs).

  • CALayer.backgroundFilters (iOS's backdrop blur) do nothing in AppKit windows, layerUsesCoreImageFilters or not, and Core Image is not even loaded (CIFilter is missing) unless something links it. Backdrop blur is an NSVisualEffectView with within-window blending; it needs wantsLayer set when created, or it has no layer to order and clip until AppKit makes one.

  • NSView.hitTest: works on frames: it ignores layer transforms and hidden layers, and does not look into subviews outside their superview‘s frame. The root’s hitTest: lets NSH's own picking decide inside the surface (input.rs native_at), or a hosted web view gets clicks on what NSH draws over it.

  • -[NSCursor set] costs ~2.5–3.5 ms per call on macOS 26 with a customized pointer size or color (it renders the cursor image again, _AXFMouseCursorGenerator), even for the cursor already set; and a view‘s default cursorUpdate:, which AppKit calls after views move or are added, sets the arrow. Set the cursor only when it changes while the mouse is still (input::set_cursor_at), and answer cursorUpdate: with NSH’s cursor: a scroll that mounts rows ten times a second otherwise spends a third of those frames setting the same cursor.

  • AppKit‘s geometry (hitTest:, convertPoint: / convertRect:, visibleRect) follows bounds, not a sublayerTransform: while a scroll offset is only the container layer’s transform, AppKit places every view below it at the last committed offset. Commit it first (scroll::commit_pending on a press, scroll::commit_ancestors before a conversion); clip and pick by NSH‘s own placed frames and scroll::bounds_origin. A view’s logical frame (ViewState::frame) misses its sticky shift and RTL mirroring: clip spaces are the placed frames (ns().frame()).

  • In define_class! methods, an early return of a bool or Option<Retained<_>> does not compile (the macro wraps the return type): write the body as one expression.

  • In-process screenshots (cacheDisplayInRect:, renderInContext:) ignore maskedCorners, shadowPath, Core Image filters and cut masks at the mask layer‘s bounds; they show no sheets or AVPlayerLayer content, and renderInContext: no WKWebView pages either (cacheDisplayInRect:, the test agent’s screenshot, shows them: WebKit draws them for it). Pages and dialogs that leave as images take their live web views and videos along (native::lend).

  • screencapture -l needs the Screen Recording permission and fails (“could not create image from window”) without it; while the screen is locked a full-screen capture is black.

Windows, Win32

  • Git checks files out with CRLF (core.autocrlf=true, Git for Windows' default), and .rustfmt.toml‘s newline_style = "unix" then makes cargo +nightly fmt --all rewrite every line of every file it touches. The repository’s .gitattributes forces LF; a checkout made before it needs git add --renormalize . (or a fresh clone).
  • rustc and clippy overflow their stack on the windows crate (its generated bindings are huge): set RUST_MIN_STACK=268435456 when a build or clippy run dies with a stack overflow.
  • Thread-local COM objects released inside ExitProcess end the process with a stray exit code: the main thread's thread-local destructors run after the graphics DLLs and their driver threads are gone. run_app runs the window on a thread of its own and joins it, so the devices, font faces and bitmaps cached per thread go at a plain thread exit.
  • Win32 timers tick at ~15.6 ms (the default timer resolution) and wake-ups come late on a loaded machine: tests must not assume two timers a few milliseconds apart run in separate turns (chain them instead), and a test's waits need slack.
  • Child windows cover everything a window draws (the “airspace” limit): web views are WebView2 composition controllers in the window‘s DirectComposition tree for this reason (compose.rs); native components that bring an HWND of their own still cover NSH content. Offscreen drawings of the window have no web views: the test agent’s screenshots capture them (PrintWindow with PW_RENDERFULLCONTENT).
  • IDCompositionVisual::AddVisual(visual, TRUE, NULL) puts the visual below all its siblings, not on top: to stack bottom to top, insert each one above the one added before it (compose.rs).
  • A focused WebView2 page has the keyboard in a window of its own (Chrome_WidgetWin_0, a child of the surface‘s window): the surface’s window gets no keys until it takes the focus back, so alerts and dialogs take it (native::take_keyboard), and the test agent's typing and keys post to that window while it has the focus. A MoveFocus right after the click needs a few hundred milliseconds before typed characters reach the page.
  • std::fs::canonicalize returns verbatim paths (\\?\C:\…), which rolldown and path prefix comparisons do not take: the bundler and the CLI canonicalize with dunce::canonicalize, which keeps plain paths when they are valid; capability checks, trusted anchors and grant roots all go through nsh_core::access::canonicalize, which does the same, so both sides of a containment check have one form.
  • Git Bash sets LANG whatever the user‘s settings: the Windows backend’s locale is the user's regional format (GetUserDefaultLocaleName) and ignores LANG / LC_*, as Windows programs do.
  • A : in a package's asset path is dangerous: joined onto the assets directory, a C: segment replaces it and name:stream writes an NTFS alternate data stream. nsh-package refuses paths with :, as with \ or ...

Web, browsers

  • WebKit does not hand a <link rel=preload as=fetch> still in flight to the fetch of the same URL: the fetch downloads the file again, and only once the preload has finished does it come from the HTTP cache (iOS 26.5 Safari: the wasm twice in 4 of 11 cold loads on Wi-Fi, each time the runtime asked before the preload was done; Chromium hands it over). The published page starts its downloads with fetch in a head script instead (web::page, fetches).

Testing on simulators and emulators

  • A manual clock stands still while a finger holds: it moves with the frames the app asks for, and a held finger asks for none, so a hold the app times itself (a ContextMenu on a touch screen) never came due on a real platform. The agent‘s long press moves the clock by the hold, as the headless driver’s does.
  • iOS simulator: launch apps only after simctl bootstatus -b; apps launched while the simulator boots are sent to the background when SpringBoard comes up.
  • Synthetic clicks on the Simulator / Catalyst windows go to whatever macOS window is on top at that point: floating windows of other apps (e.g. a Slack huddle bar at window level 1001) silently swallow clicks over a band of the app. When a tap does nothing, list the windows at the point (CGWindowListCopyWindowInfo) before suspecting hit testing.
  • Not supported / not verifiable here: keeping left and right unswapped right to left (left / right always swap right to left); after switching direction at runtime UIKit keeps an existing back button‘s chevron; Android has no skew transforms (as in React Native); the iOS simulator cannot inject edge swipes (interactive back is untested there); Android’s stylus handwriting mode (on by default on recent emulators) intercepts taps on inputs — turn it off with adb shell settings put secure stylus_handwriting_enabled 0.