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.
What to try
- Wait for the counter to load (
counter=in the card header), then press Read as int:read=intand the value. - Press Read as String:
read=QueryDataTypeError, withexpected=Stringandactual=int. - Press Write a String:
write=QueryDataTypeError, and thecounterstrip still readsupdates=1, because the refused write left the entry as it was. - Press Mutate without a function: the mutation ends in
status=errorwitherror=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:
/// 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:
/// 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:
/// 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
/// 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),
],
],
);
}
}
Related
- Guides: Type safety in Dart, Mutations, Debugging
- Tested by
test/features/diagnostics_test.dart(widget) ande2e/tests/diagnostics.spec.ts(browser) - View the feature on GitHub