Skip to content

[BUG]: genui's Slider is a two-position switch over its default range, and crashes below it #2737

Closed
flutter/genui
#1039

Description

@diegolopezrm
  • I have searched the existing issues to make sure this bug has not already been reported.

Describe the Bug

slider.dart derives the number of stops from the range itself:

divisions: (sliderData.max - sliderData.min).toInt(),

The schema documents min as defaulting to 0.0 and max to 1.0. That range gives divisions: 1, which means the thumb can only rest at the two ends. Every value between them snaps, and a 0.5 is announced by assistive technology as 100%. A brightness, volume or opacity slider built on the documented defaults is a switch.

A range narrower than 1 is worse: min: 0, max: 0.5 gives divisions: 0 and Flutter asserts.

'package:flutter/src/material/slider.dart': Failed assertion: line 209 pos 15:
'divisions == null || divisions > 0': is not true.

So an agent can take the surface down with a message the catalog schema accepts.

Two smaller things in the same widget:

  • The number shown next to the thumb is value?.toStringAsFixed(0), read from the data model only. With a literal value it shows the minimum instead, and toStringAsFixed(0) means a 0-to-1 slider can only ever read 0 or 1.
  • Because of the snapping, the value the user leaves behind is not the value the agent sees: the write goes through onChanged, so the data model receives the snapped number.

Steps to Reproduce

import 'package:a2ui_core/a2ui_core.dart' as core;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:genui/genui.dart';

Future<SurfaceController> show(
    WidgetTester tester, List<Map<String, dynamic>> components) async {
  final catalog = BasicCatalogItems.asCatalog().copyWith(catalogId: 'repro');
  final controller = SurfaceController(catalogs: [catalog]);
  controller.handleMessage(
      core.UpdateComponentsMessage(surfaceId: 's', components: components));
  controller.handleMessage(
      core.CreateSurfaceMessage(surfaceId: 's', catalogId: 'repro'));
  await tester.pumpWidget(MaterialApp(
      home: Scaffold(body: Surface(surfaceContext: controller.contextFor('s')))));
  await tester.pumpAndSettle();
  return controller;
}

void main() {
  testWidgets('the default range is not a two-position switch', (tester) async {
    await show(tester, [
      {'id': 'root', 'component': 'Slider', 'value': 0.5, 'min': 0, 'max': 1},
    ]);

    expect(tester.widget<Slider>(find.byType(Slider)).divisions, isNot(1));
  });

  testWidgets('a sub-unit range does not crash', (tester) async {
    await show(tester, [
      {'id': 'root', 'component': 'Slider', 'value': 0.2, 'min': 0, 'max': 0.5},
    ]);

    expect(tester.takeException(), isNull);
  });
}

The first fails with Expected: not <1> / Actual: <1>; the second with the assertion above.

Expected Behavior

A slider is continuous unless something says otherwise: divisions: null by default, and a declared step — a step property on the component, or divisions derived from it — when the catalog wants discrete stops. Whatever the rule ends up being, it cannot produce 0, and it should not turn the documented default range into two positions.

Environment Details

  • OS: macOS 26.5.2
  • Browser/Platform: Flutter 3.44.6 stable, Dart 3.10
  • SDK/Package Name & Version: genui 0.10.3 (main, d88aa76), a2ui_core 0.1.1
  • Protocol Version: v0.9.1

Additional Context

Found while prototyping a rendering conformance suite that states what a surface must expose as semantics — role, name, value, state, actions — so the same cases can run against Flutter, SwiftUI, Compose and the web. The case declared value: "0.5" and this renderer announced 100%, which is what led back to the divisions.

Worth saying that a golden or screenshot test would not have caught this one: the slider looks right. What is wrong is where the thumb is allowed to stop and what it tells a screen reader.

Happy to send the PR.

Activity

  1. added
    status: needs-triageauto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs
    on Sep 22, 2026
  2. diegolopezrm commented on Sep 22, 2026

    @diegolopezrm
    ContributorAuthor

    The prototype this came out of is written up in #2738, in case the shape of the suite is worth discussing separately from the fix.

  3. josemontespg commented on Sep 22, 2026

    @josemontespg
    Collaborator

    A2UI Triage: Confirmed on genui main (d88aa76): slider.dart derives divisions from max - min, so the documented default range renders two stops and a range narrower than 1 trips Flutter's divisions > 0 assertion in debug and profile builds. The spec matches your expectation: v0.9.1 has no snapping and v1.0 snaps only when steps (integer, minimum 1) is set. The spec's own music player example (max: 1) is affected.

    Marked P2 and assigned to @gspencergoog. A PR is welcome; @gspencergoog, please say here whether you want to take it or review one from @diegolopezrm, since the fix also decides how v1.0 steps maps to divisions (#1959).

  4. removed
    status: needs-triageauto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs
    on Sep 22, 2026
  5. moved this from Todo to In Progress in A2UIon Sep 23, 2026
  6. added a commit that references this issue on Sep 30, 2026
    e3e8857
  7. added 2 commits that reference this issue on Oct 2, 2026
    f8d8763
    313a943
  8. added a commit that references this issue on Nov 3, 2026
    5cecedd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions