Skip to main content

Diagnostics

A cache entry holds one exact type. A read or write that names another type throws QueryDataTypeError from the call itself, before any future exists, and the error names the type asked for and the type held. A mutation run with no mutationFn and no default registered for its key fails with MissingMutationFunctionError as its error state, and the message names the cure, QueryClient.setMutationDefaults. Both show up in real apps the same way: a product detail screen that reads the list entry as the wrong model class, or a mutation key whose default is registered in a setup function that has not run yet.

Live demoDiagnosticsWhat the library throws, and when: the wrong type, the missing function.~3 MB, runs in your browser; no server involved.

What to try​

  • Wait for the counter to load (counter= in the card header), then press Read as int: read=int and the value.
  • Press Read as String: read=QueryDataTypeError, with expected=String and actual=int.
  • Press Write a String: write=QueryDataTypeError, and the counter strip still reads updates=1, because the refused write left the entry as it was.
  • Press Mutate without a function: the mutation ends in status=error with error=MissingMutationFunctionError, and nothing is sent.
  • Press Register a default mutationFn, then Mutate without a function again: the same mutation now runs the default and ends in status=success.

The code​

The counter is fetched as an int, and the mutation has a key and nothing else:

examples/showcase/lib/features/diagnostics/diagnostics_screen.dart · lines 58–68
/// The counter, as an `int`: the one exact type the entry holds from then on.
QueryObserverOptions<int> counterQuery(ShowcaseApi api) =>
QueryObserverOptions<int>(
queryKey: diagnosticsCounterKey,
queryFn: (context) => api.counter(signal: context.signal),
);

/// A mutation with a key and no function. `retry: never` is a mutation's
/// default anyway, and the library forces it while the function is missing.
MutationOptions<int, int, void> noFunctionMutation() =>
MutationOptions.simple<int, int>(mutationKey: noFunctionKey);

The wrong-type read throws synchronously, so an ordinary try catches it:

examples/showcase/lib/features/diagnostics/diagnostics_screen.dart · lines 92–106
/// A read that names another type. It throws before any future exists —
/// from the call — so a plain `try` is where it is caught.
void _readAsString() {
final client = QueryClientProvider.of(context);
try {
final value = client.getQueryData<String>(diagnosticsCounterKey);
setState(() => _read = 'String $value');
} on QueryDataTypeError catch (error) {
setState(() {
_read = 'QueryDataTypeError';
_expected = '${error.expected}';
_actual = '${error.actual}';
});
}
}

The cure is a default for every mutation under the key. The mutation's reader applies its options again on the next build, and the default supplies the function:

examples/showcase/lib/features/diagnostics/diagnostics_screen.dart · lines 124–136
/// The cure the error message names: a default for every mutation under
/// the key. The observer re-applies its options on the rebuild, and the
/// default fills the function in.
void _registerDefault() {
final api = ShowcaseScope.apiOf(context);
QueryClientProvider.of(context).setMutationDefaults(
noFunctionKey,
MutationDefaults(
mutationFn: (variables) => api.increment(by: variables! as int),
),
);
setState(() => _defaultRegistered = true);
}
The whole screen
examples/showcase/lib/features/diagnostics/diagnostics_screen.dart
/// Port-specific: what the library throws, and when. Two errors upstream
/// does not have, because they guard what TypeScript checks at compile time
/// and JavaScript lets slide at run time:
///
/// * [QueryDataTypeError] — one key, one exact type. A read or write that
/// names a type other than the one the entry holds throws *synchronously*,
/// from the call itself — `getQueryData`, `setQueryData`, `getQueriesData`,
/// an observer's `setOptions` — rather than handing back a value that is
/// not what the caller asked for. The error names
/// the key, the type asked for and the type held.
/// * [MissingMutationFunctionError] — a mutation run with no `mutationFn` and
/// no default registered for its key fails with it, as its error state, the
/// way upstream's "No mutationFn found" rejects. The message names the cure:
/// `QueryClient.setMutationDefaults`, and the same button here registers
/// one and the next run succeeds.
///
/// The counter (`GET /api/counter`) is the entry the typed reads are made
/// against, read through a `QueryBuilder<int>`; the function-less mutation is
/// a `context.mutation`.
///
/// Proofs (widget tests in `test/features/diagnostics_test.dart`, end-to-end
/// in `e2e/tests/diagnostics.spec.ts`): a read as `int` answers the cached
/// value and a read as `String` throws `QueryDataTypeError` naming `String`
/// and `int`; a write of a `String` throws the same and leaves the entry as it
/// was, `updates=1`; a mutation without a function ends in `status=error`
/// with `MissingMutationFunctionError` and sends nothing; and once a default
/// `mutationFn` is registered for its key the same mutation succeeds with
/// `data=1`, one `POST`.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/controls.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/scope.dart';

const Feature diagnosticsFeature = Feature(
id: 'diagnostics',
title: 'Diagnostics',
summary: 'What the library throws, and when: the wrong type, the missing '
'function.',
);

/// The entry the typed reads are made against. This screen's own key.
QueryKey get diagnosticsCounterKey =>
QueryKey(const <Object?>['diagnostics', 'counter']);

/// The function-less mutation's key — what `setMutationDefaults` addresses.
QueryKey get noFunctionKey =>
QueryKey(const <Object?>['diagnostics', 'no-function']);

/// The counter, as an `int`: the one exact type the entry holds from then on.
QueryObserverOptions<int> counterQuery(ShowcaseApi api) =>
QueryObserverOptions<int>(
queryKey: diagnosticsCounterKey,
queryFn: (context) => api.counter(signal: context.signal),
);

/// A mutation with a key and no function. `retry: never` is a mutation's
/// default anyway, and the library forces it while the function is missing.
MutationOptions<int, int, void> noFunctionMutation() =>
MutationOptions.simple<int, int>(mutationKey: noFunctionKey);

class DiagnosticsScreen extends StatefulWidget {
const DiagnosticsScreen({super.key});


State<DiagnosticsScreen> createState() => _DiagnosticsScreenState();
}

class _DiagnosticsScreenState extends State<DiagnosticsScreen> {
String _read = 'none';
String _write = 'none';
String _expected = 'none';
String _actual = 'none';
bool _defaultRegistered = false;

/// A read that names the entry's own type: the value, or null if nothing
/// is cached yet.
void _readAsInt() {
final value = QueryClientProvider.of(context)
.getQueryData<int>(diagnosticsCounterKey);
setState(() => _read = 'int $value');
}

/// A read that names another type. It throws before any future exists —
/// from the call — so a plain `try` is where it is caught.
void _readAsString() {
final client = QueryClientProvider.of(context);
try {
final value = client.getQueryData<String>(diagnosticsCounterKey);
setState(() => _read = 'String $value');
} on QueryDataTypeError catch (error) {
setState(() {
_read = 'QueryDataTypeError';
_expected = '${error.expected}';
_actual = '${error.actual}';
});
}
}

/// A write of another type: refused the same way, and the entry is left
/// exactly as it was.
void _writeString() {
final client = QueryClientProvider.of(context);
try {
client.setQueryData<String>(diagnosticsCounterKey, 'not a number');
setState(() => _write = 'String written');
} on QueryDataTypeError catch (error) {
setState(() {
_write = 'QueryDataTypeError';
_expected = '${error.expected}';
_actual = '${error.actual}';
});
}
}

/// The cure the error message names: a default for every mutation under
/// the key. The observer re-applies its options on the rebuild, and the
/// default fills the function in.
void _registerDefault() {
final api = ShowcaseScope.apiOf(context);
QueryClientProvider.of(context).setMutationDefaults(
noFunctionKey,
MutationDefaults(
mutationFn: (variables) => api.increment(by: variables! as int),
),
);
setState(() => _defaultRegistered = true);
}


Widget build(BuildContext context) {
final api = ShowcaseScope.apiOf(context);
return FeatureScaffold(
feature: diagnosticsFeature,
children: <Widget>[
SectionCard(
title: 'One key, one type',
trailing: QueryBuilder<int>(
options: counterQuery(api),
builder: (context, counter) => Text(
switch (counter) {
QueryPending() => 'counter=…',
QueryError(staleData: null) => 'counter=error',
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
'counter=$data',
},
style: monoStyle,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'The entry holds an int, fetched by the builder up here. A '
'read or write that names another type throws '
'QueryDataTypeError from the call itself — synchronously, '
'before any future exists — instead of handing back '
'something that is not what was asked for. The error names '
'the key, the type asked for and the type held.',
),
const SizedBox(height: 12),
Toolbar(
children: <Widget>[
ActionButton(label: 'Read as int', onPressed: _readAsInt),
ActionButton(
label: 'Read as String', onPressed: _readAsString),
ActionButton(
label: 'Write a String', onPressed: _writeString),
],
),
const SizedBox(height: 8),
FactGroup(
name: 'facts typed',
facts: <String>[
'read=$_read',
'write=$_write',
'expected=$_expected',
'actual=$_actual',
],
),
],
),
),
QueryDebugStrip(queryKey: diagnosticsCounterKey, label: 'counter'),
SectionCard(
title: 'A mutation without a function',
child: _NoFunctionCard(
defaultRegistered: _defaultRegistered,
onRegisterDefault: _registerDefault,
),
),
],
);
}
}

/// The error's name by an `is` check, not `runtimeType`: a web build
/// minifies type names, and the fact is read as an exact text.
String _nameOf(Object error) => switch (error) {
MissingMutationFunctionError() => 'MissingMutationFunctionError',
QueryDataTypeError() => 'QueryDataTypeError',
_ => 'other',
};

/// Its own widget so `context.mutation` re-applies its options on the
/// rebuild that follows the default being registered — which is when the
/// default fills the function in.
class _NoFunctionCard extends StatelessWidget {
const _NoFunctionCard({
required this.defaultRegistered,
required this.onRegisterDefault,
});

final bool defaultRegistered;
final VoidCallback onRegisterDefault;


Widget build(BuildContext context) {
final mutation = context.mutation(noFunctionMutation());
final result = mutation.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'MutationOptions.simple(mutationKey: …) and nothing else: no '
'mutationFn, and no default registered for the key. Running it '
'fails with MissingMutationFunctionError as the mutation\'s error '
'state — nothing is sent — and the message names the cure. '
'Register a default mutationFn for the key, and the same mutation '
'runs it.',
),
const SizedBox(height: 12),
Toolbar(
children: <Widget>[
ActionButton(
label: 'Mutate without a function',
filled: true,
onPressed: () => mutation.mutate(1),
),
ActionButton(
label: 'Register a default mutationFn',
onPressed: defaultRegistered ? null : onRegisterDefault,
),
],
),
const SizedBox(height: 8),
FactGroup(
name: 'facts no-function',
facts: <String>[
'status=${result.status.name}',
if (result case MutationError(:final error))
'error=${_nameOf(error)}',
if (result case MutationSuccess(:final data)) 'data=$data',
'default=${defaultRegistered ? 'registered' : 'none'}',
],
),
if (result case MutationError(:final error)) ...<Widget>[
const SizedBox(height: 8),
Notice('$error', error: true),
],
],
);
}
}