Skip to content

web: EngineSemanticsOwner.updateSemantics throws Null check operator used on a null value when a sent parent lists a child id with no SemanticsObject, and never recovers the tree #193235

Description

@amantinband

Environment: Flutter 3.44.8 stable, engine 0cd6107, Dart 3.12.2, release
--wasm (skwasm) web build. Semantics enabled for the whole session.

Steps: run a Flutter web app with the semantics tree enabled from boot
(SemanticsBinding.ensureSemantics() held for life). Drive a UI whose semantics
updates can leave a node detached while a parent update still enumerates that
node's id among its children.

Expected: a child id with no live SemanticsObject is skipped (or the tree
is reconciled), and the accessibility tree keeps updating.

Actual: owner._semanticsTree[childId]! throws TypeError: Null check operator used on a null value inside the render-order computation
(semantics.dart:2040 on the shipped bytes). The throw escapes before
_finalizeTree(), so _detachments and the dirty child lists persist and every
later updateSemantics throws again (:2617). The exception is caught upstream
of the app, so pixels and input keep working while the accessibility DOM tree
freezes for the life of the page and every addOneTimePostUpdateCallback of a
failed update (including a semantic text field's DOM focus on WebKit) never runs.

Cause: the render-order pass dereferences a child id from a parent's
childrenInHitTestOrder / childrenInTraversalOrder through a bare null-check
instead of tolerating a missing child, and the throw lands before the frame's
_finalizeTree(), so the owner is never reconciled and stays poisoned.

Aggravating: EngineSemanticsOwner.reset() calls _finalizeTree() before it
clears _semanticsTree and _detachments, so a setSemanticsTreeEnabled(false)
issued to recover from the poisoned state re-walks the stale lists and can throw
without clearing anything.

Fix direction: skip a child id with no live SemanticsObject in the
render-order pass (guard the !), and reconcile the tree in a finally so a
throw during an update cannot leave _detachments and dirty child lists stranded
for the rest of the session. Clearing _semanticsTree/_detachments before
_finalizeTree() in reset() would additionally make disable-then-enable a
reliable recovery.

Field evidence: continuous production occurrences on builds that already carry
the #177069 fix (about 35 rows in the six weeks before 2026-09-16, 25 on the
morning of 2026-09-16 on one build), classified as one stable web_semantics_desync
bucket. Closest prior issue #175180 (fixed by #177069) is the same
family but a different trigger, so this is an unfixed variant. No headless repro
yet. The fingerprint is web + scheduler library + the exact null-check TypeError
text + semantics enabled.

Activity

  1. suryaKommana2662 commented on Sep 24, 2026

    @suryaKommana2662
    Contributor

    @amantinband Thanks for the report. To help reproduce this locally, could you provide a minimal reproducible example, repository, or main.dart. Also, could you verify whether the issue still occurs on the latest stable release and provide any relevant logs or stack traces from the failure.

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Sep 24, 2026
  3. amantinband commented on Sep 24, 2026

    @amantinband
    Author

    Thanks for the quick triage. Three parts: a minimal reproducible example, verification on the latest stable, and production data since the report, plus two corrections to the mechanism described above.

    1. Minimal reproducible example (dart:ui level, no widgets). The app below enables semantics the way our product does, then sends one raw semantics update in which the root lists children [1, 2] while node 2 is never sent, and prints what the engine does on the following updates.

    // Minimal reproduction for flutter/flutter#193235 (Flutter web).
    //
    // Bug: when an `updateSemantics` carries a parent node that lists a child id for
    // which the engine has NO `SemanticsObject`, `EngineSemanticsOwner` dereferences
    // it through a bare `!` and throws `TypeError: Null check operator used on a null
    // value`. The throw escapes before `_finalizeTree()`; a sender that keeps listing
    // the missing child re-throws on every update (pixels + input keep working). Watch
    // the browser console for the `REPRO ...` lines below.
    import 'dart:typed_data';
    import 'dart:ui' as ui;
    
    import 'package:flutter/semantics.dart';
    import 'package:flutter/widgets.dart';
    
    final Float64List _identity =
        Float64List.fromList(<double>[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
    
    SemanticsHandle? _handle;
    
    // Contribute one node [id] to [b] whose children (both orders) are [kids].
    void _node(ui.SemanticsUpdateBuilder b, int id, List<int> kids) {
      final Int32List c = Int32List.fromList(kids);
      b.updateNode(
        id: id, flags: SemanticsFlags.none, actions: 0,
        maxValueLength: -1, currentValueLength: -1,
        textSelectionBase: -1, textSelectionExtent: -1, platformViewId: -1,
        scrollChildren: 0, scrollIndex: 0, traversalParent: -1,
        scrollPosition: double.nan, scrollExtentMax: double.nan, scrollExtentMin: double.nan,
        rect: const ui.Rect.fromLTWH(0, 0, 100, 100),
        identifier: '', label: '', labelAttributes: const <StringAttribute>[],
        value: '', valueAttributes: const <StringAttribute>[],
        increasedValue: '', increasedValueAttributes: const <StringAttribute>[],
        decreasedValue: '', decreasedValueAttributes: const <StringAttribute>[],
        hint: '', hintAttributes: const <StringAttribute>[], tooltip: '',
        transform: _identity, hitTestTransform: _identity,
        childrenInTraversalOrder: c, childrenInHitTestOrder: c,
        additionalActions: Int32List(0), controlsNodes: null,
        inputType: ui.SemanticsInputType.none, locale: null, minValue: '', maxValue: '',
      );
    }
    
    // Push an update built from [nodes] (id -> children) straight to the engine
    // owner. Returns the caught exception text, or null on success.
    String? _send(List<(int, List<int>)> nodes) {
      try {
        final b = ui.SemanticsUpdateBuilder();
        for (final (id, kids) in nodes) {
          _node(b, id, kids);
        }
        ui.PlatformDispatcher.instance.views.first.updateSemantics(b.build());
        return null;
      } catch (e, s) {
        return '$e\n$s';
      }
    }
    
    void _run() {
      // c: parent 0 lists [1,2]; node 1 is sent, node 2 is NOT -> engine has no object for 2.
      print('REPRO step c: ${_send(<(int, List<int>)>[(0, [1, 2]), (1, [])]) ?? "NO THROW (unexpected)"}');
      // d: exactly the brief's "valid" update (0 -> [1], node 1). Does the poison persist?
      print('REPRO step d: ${_send(<(int, List<int>)>[(0, [1]), (1, [])]) ?? "NO THROW (recovered)"}');
      // d2: the field shape - re-send the SAME stale child list (framework keeps enumerating 2).
      print('REPRO step d2: ${_send(<(int, List<int>)>[(0, [1, 2]), (1, [])]) ?? "NO THROW (recovered)"}');
      // e: the engine's offered recovery - drop the last handle (runs reset()), re-ensure, resend.
      bool resetThrew = false;
      try {
        _handle?.dispose();
      } catch (e, s) {
        resetThrew = true;
        print('REPRO step e reset THREW: $e\n$s');
      }
      print('REPRO step e reset threw=$resetThrew');
      try {
        _handle = SemanticsBinding.instance.ensureSemantics();
        print('REPRO step e re-ensure ok');
      } catch (e, s) {
        print('REPRO step e re-ensure THREW: $e\n$s');
      }
      print('REPRO step e resend: ${_send(<(int, List<int>)>[(0, [1]), (1, [])]) ?? "NO THROW (recovered)"}');
      print('REPRO done');
    }
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      FlutterError.onError = (d) => print('REPRO FlutterError.onError: ${d.exception}\n${d.stack}');
      ui.PlatformDispatcher.instance.onError = (e, s) {
        print('REPRO PlatformDispatcher.onError: $e\n$s');
        return true;
      };
      _handle = SemanticsBinding.instance.ensureSemantics(); // arm like the app; hold for life
      runApp(const SizedBox.shrink());
      WidgetsBinding.instance.addPostFrameCallback((_) {
        // Let the framework's own first semantics send settle before we inject.
        Future<void>.delayed(const Duration(seconds: 2), _run);
      });
    }

    Run flutter build web --wasm --release, serve the build directory (COOP/COEP headers for skwasm), open it, read the console. Same result with the JS build (flutter build web --release), on Chromium and on WebKit.

    Console on Flutter 3.47.5 stable (engine af7e796, Dart 3.13.4), --wasm --release:

    REPRO step c: Null check operator used on a null value
        at M (main.dart.wasm:wasm-function[2720]:0xb1984)
        at M (main.dart.wasm:wasm-function[2700]:0xaf4f0)
        at M (main.dart.wasm:wasm-function[2693]:0xaf01e)
        at M (main.dart.wasm:wasm-function[2294]:0xa4c38)
        at M (main.dart.wasm:wasm-function[1561]:0x93050)
        at M (main.dart.wasm:wasm-function[579]:0x771ed)
    REPRO step d: NO THROW (recovered)
    REPRO step d2: Null check operator used on a null value
        at M (main.dart.wasm:wasm-function[2720]:0xb1984)
    REPRO step e reset threw=false
    REPRO step e re-ensure ok
    REPRO step e resend: NO THROW (recovered)
    REPRO done
    

    Identical outcome on 3.44.8 (engine 0cd6107, Dart 3.12.2). Symbolized through the JS build and its source map (3.44.8), the step c frames are:

    main.dart.js:13373:3  ->  lib/_engine/engine/semantics/semantics.dart:2040:81   SemanticsObject.updateChildren
    main.dart.js:14012:3  ->  lib/_engine/engine/semantics/semantics.dart:3247:6    EngineSemanticsOwner.updateSemantics
    main.dart.js:8624:9   ->  lib/_engine/engine/window.dart:109:14                 FlutterView.updateSemantics
    main.dart.js:8630:64  ->  lib/main.dart:59:25                                   _run
    

    semantics.dart:2040 is the same line our production stacks symbolize to (part 4).

    2. Latest stable. Reproduces on 3.47.5 as shown. The engine source at the 3.47.5 tag (engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart) is unchanged on every path involved; line numbers from that file:

    • SemanticsObject.updateChildren: childrenInRenderOrder.add(owner._semanticsTree[childrenInTraversalOrder[i]]!) (line 2079), the first throw.
    • recomputeChildrenAdjustment: owner._semanticsTree[childIndex]! (line 2515).
    • _visitDepthFirstInTraversalOrder: the lookup is guarded only by an assert, then child! (line 2656). This is where our production sessions throw on later frames (semantics.dart:2617 on the shipped 3.44.8 copy).
    • updateSemantics calls _finalizeTree() at line 3310 on the success path only.

    3. Two corrections to the report, from the repro.

    • "Never recovers the tree" is producer-driven, not a bricked owner. A later update that no longer lists the missing id (step d) completes, and dispose plus ensureSemantics() (step e) also completes without reset() throwing. What keeps a page broken is that the sender keeps enumerating the missing child on every frame: step d2 re-sends the same list and throws every time. In this minimal shape the first throw happens inside updateChildren before any attach or detach runs, so _detachments stays empty; the report's claim that _detachments is stranded does not hold here. A sequence that detaches a node in one update and lists it again later may still behave as the report says; we did not build that one.
    • So the trigger is on the framework side: some send path produces a parent whose child list names a node the engine never received, and keeps doing so for the rest of the session. We have not isolated the widget in our app.

    Given that, the engine change we would suggest is resilience at the three dereference sites: skip a child id with no live SemanticsObject (with an assert in debug so the producer bug still surfaces), so a framework-side inconsistency degrades to one missing node instead of freezing the accessibility tree for the life of the page.

    4. Production data since the report. We classify this failure on both our dart2js and dart2wasm builds (web, scheduler library, the exact TypeError text, semantics enabled), and since 2026-09-17 we run a one-shot recovery on the first occurrence in a session: dispose the boot SemanticsHandle, ensureSemantics() again.

    • 12 occurrences on 2026-09-17 across two builds and three sessions (11 dart2js, 1 wasm), none from 2026-09-18 to 2026-09-24. So there is no fresh production occurrence to replay.
    • The recovery did not hold in the field: in the one session we can line up, the reset ran 22 ms after the first throw without throwing itself, and the same TypeError came back three more times over the next 11 minutes, consistent with the producer re-sending the inconsistent parent after re-enable. In another session (7 throws in 13 minutes) it never fired.
    • Production stack (dart2js build, symbolized): SemanticsObject.updateChildren (semantics.dart:2040:7), from the framework's lib/src/semantics/semantics.dart:5038, from PipelineOwner.flushSemantics, from RendererBinding.drawFrame, from SchedulerBinding.handleBeginFrame.

    Happy to run anything else that helps.

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 24, 2026
  5. suryaKommana2662 commented on Sep 25, 2026

    @suryaKommana2662
    Contributor

    @amantinband Thanks for providing the minimal reproduction and confirming that the issue reproduces on the latest stable release. The additional stack trace are helpful. routing to the team-accessibility for further investigation.
    Related issues: #175180 and the fix in #177069

  6. added
    fyi-webFor the attention of Web platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    and removed
    in triagePresently being triaged by the triage team
    on Sep 25, 2026
  7. amantinband commented on Sep 25, 2026

    @amantinband
    Author

    One correction to my comment above, on step e of the repro. Disposing our SemanticsHandle and calling ensureSemantics() again never reached EngineSemanticsOwner.reset(): on the web the engine turns semantics on by itself at the first updateSemantics (EngineSemantics.didReceiveSemanticsUpdate), and the framework then holds its own platform-driven handle (SemanticsBinding._handleSemanticsEnabledChanged), so releasing the app's handle leaves the tree enabled and setSemanticsTreeEnabled(false) is never called. Step e's resend recovered for the same reason step d did (a corrected child list), not because reset() ran clean. So "reset() did not throw" is untested by this repro. Reading the source, reset() walks the stored child lists through the same bare dereferences before it clears them, so on the poisoned state it would be expected to throw, which was the report's original concern; we have not reproduced that. The rest of the comment stands.

  8. self-assigned this
    on Oct 7, 2026
  9. flutter-triage-bot commented on Oct 10, 2026

    @flutter-triage-bot

    This issue is missing a priority label. Please set a priority label when adding the triaged-accessibility label.

  10. added
    P2Important issues not at the top of the work list
    on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listteam-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)triaged-accessibilityTriaged by Framework Accessibility team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions