Development-mode checks that turn watch's silent failures into loud ones.
Five real bugs were found by building two applications on watch, and all five
were silent — wrong output or frozen output, never an exception:
| # | mistake | symptom |
|---|---|---|
| 1 | two sibling fragments read one topic; only one was patched | one goes stale |
| 2 | a fragment read one topic twice; the second read overwrote the first | pruning suppresses a real change |
| 3 | a reader rebuilt its collection every call | pruning never fires; slow, not wrong |
| 4 | a value was passed in as an argument instead of read via watch | fragment subscribes to nothing, freezes |
| 5 | a topic name was invented at the read site that nothing publishes | fragment never updates |
1 and 2 were fixed in the engine and are now covered by tests. 3, 4 and 5 are application mistakes the engine cannot fix, because the application is free to write whatever readers it likes. So they are detected here instead.
Every check needs work the hot path should not do: calling readers a second time and
keeping a registry of published topics. So these are not wired into the engine — they
are called explicitly, from a test or a REPL, via check-component.
A render-fn wrapper was tried first and removed: by the time a render-fn runs the
tree is already built and the recording is gone, so it could see nothing worth
checking. Detection has to happen where the recording is, which is why the entry
point takes a component rather than wrapping the renderer.
Plain hiccup that is not wrapped in fragment. The two styles mix — a component may
hold watch-managed fragments alongside markup the application pushes itself — so an
element with an id but no fragment is a legitimate choice, not a mistake.
Development-mode checks that turn `watch`'s silent failures into loud ones. ## Why this exists Five real bugs were found by building two applications on `watch`, and **all five were silent** — wrong output or frozen output, never an exception: | # | mistake | symptom | |---|---|---| | 1 | two sibling fragments read one topic; only one was patched | one goes stale | | 2 | a fragment read one topic twice; the second read overwrote the first | pruning suppresses a real change | | 3 | a reader rebuilt its collection every call | pruning never fires; slow, not wrong | | 4 | a value was passed in as an argument instead of read via `watch` | fragment subscribes to nothing, freezes | | 5 | a topic name was invented at the read site that nothing publishes | fragment never updates | 1 and 2 were fixed in the engine and are now covered by tests. 3, 4 and 5 are **application** mistakes the engine cannot fix, because the application is free to write whatever readers it likes. So they are detected here instead. ## Cost, and why this is opt-in Every check needs work the hot path should not do: calling readers a second time and keeping a registry of published topics. So these are not wired into the engine — they are called explicitly, from a test or a REPL, via `check-component`. A `render-fn` wrapper was tried first and removed: by the time a `render-fn` runs the tree is already built and the recording is gone, so it could see nothing worth checking. Detection has to happen where the recording is, which is why the entry point takes a component rather than wrapping the renderer. ## What is deliberately NOT checked Plain hiccup that is not wrapped in `fragment`. The two styles mix — a component may hold `watch`-managed fragments alongside markup the application pushes itself — so an element with an id but no fragment is a legitimate choice, not a mistake.
(check recording
&
{:keys [allow-silent known-topics]
:or {allow-silent #{} known-topics #{}}})Runs every check against one render-recording result. Returns a problem seq.
Runs every check against one `render-recording` result. Returns a problem seq.
(check-component component-fn params & opts)Renders component-fn with params under a recording and reports problems.
This is the practical entry point: it is what a test or a REPL calls to find out whether a component has any of the three application-level defects, without needing an engine or a connection.
(diagnose/check-component my-app {:conn-id "c1"})
;=> [{:problem :silent-fragment :fragment "row-1" :message "..."}]
Renders `component-fn` with `params` under a recording and reports problems.
This is the practical entry point: it is what a test or a REPL calls to find out
whether a component has any of the three application-level defects, without needing
an engine or a connection.
(diagnose/check-component my-app {:conn-id "c1"})
;=> [{:problem :silent-fragment :fragment "row-1" :message "..."}](note-published! topic)Records that topic was published. Call from the application's publish path.
Records that `topic` was published. Call from the application's publish path.
(orphan-topics recording & {:keys [known] :or {known #{}}})Topics a render subscribed to that nothing has ever published.
This is bug 5, and it is the one that cost the most time: porting the chat app I
wrote [:members channel-id] where the server published [:channel channel-id].
The first render was correct, the fragment never updated again, and nothing errored —
because a topic name invented at the read site has no counterpart at the publish site
to disagree with.
Necessarily a heuristic: a topic may be legitimately unpublished at the moment a render happens and published later. So this is best read after an application has been exercised, and it warns rather than throws.
Pass :known for topics that are expected never to be published — per-connection UI
state whose publisher runs only on interaction, for example.
Topics a render subscribed to that nothing has ever published. This is bug 5, and it is the one that cost the most time: porting the chat app I wrote `[:members channel-id]` where the server published `[:channel channel-id]`. The first render was correct, the fragment never updated again, and nothing errored — because a topic name invented at the read site has no counterpart at the publish site to disagree with. Necessarily a *heuristic*: a topic may be legitimately unpublished at the moment a render happens and published later. So this is best read after an application has been exercised, and it warns rather than throws. Pass `:known` for topics that are expected never to be published — per-connection UI state whose publisher runs only on interaction, for example.
Every topic the application has published, for orphan-topics.
A set rather than a count: the question is whether a topic has EVER been published, and a topic published once is enough to prove the name is real.
Every topic the application has published, for `orphan-topics`. A set rather than a count: the question is whether a topic has EVER been published, and a topic published once is enough to prove the name is real.
(silent-fragments recording & {:keys [allow] :or {allow #{}}})Fragments that read no topic at all.
This is bug 4. A fragment given its data as an argument rather than reading it
through watch renders correctly once and then never again — nothing can invalidate
it, because it declared no dependency. Found in the dashboard, where job-row took
the job map from the list instead of watching [:job id], so a progress bar that
changes ten times a second would have been frozen at whatever the list last held.
A fragment with no reads is sometimes intentional — a static panel that is patchable
but never invalidated — so this is a report rather than an error. Pass :allow to
narrow it to the ones that surprise you.
Fragments that read no topic at all. This is bug 4. A fragment given its data as an argument rather than reading it through `watch` renders correctly once and then never again — nothing can invalidate it, because it declared no dependency. Found in the dashboard, where `job-row` took the job map from the list instead of watching `[:job id]`, so a progress bar that changes ten times a second would have been frozen at whatever the list last held. A fragment with no reads is sometimes intentional — a static panel that is patchable but never invalidated — so this is a *report* rather than an error. Pass `:allow` to narrow it to the ones that surprise you.
(unstable-readers recording)Readers whose value is not identical? across two consecutive calls.
This is bug 3, and it is the most insidious of the five because nothing is wrong —
the screen is correct, it is just re-rendered on every hint. Found in the dashboard,
where all-jobs sorted into a fresh vector and summary built a fresh map per call,
so the two SLOWEST fragments re-rendered ten times a second.
watch compares reads with identical? first and falls back to = only for
scalars, so a reader that rebuilds a collection always compares as changed. The fix
is to compute derived values once at write time and hand out the cached value.
Returns [{:fragment id :topic t :type class}], empty when everything is stable.
Scalars are skipped: a reader returning a fresh Long or String is fine, because
same? falls back to = for those.
Reported once per topic, against the innermost fragment that reads it. Topics bubble up to containing fragments, so a naive walk reports the same reader once per ancestor — three copies of one problem for a three-deep tree, which buries the signal it exists to raise.
Readers whose value is not `identical?` across two consecutive calls.
This is bug 3, and it is the most insidious of the five because nothing is *wrong* —
the screen is correct, it is just re-rendered on every hint. Found in the dashboard,
where `all-jobs` sorted into a fresh vector and `summary` built a fresh map per call,
so the two SLOWEST fragments re-rendered ten times a second.
`watch` compares reads with `identical?` first and falls back to `=` only for
scalars, so a reader that rebuilds a collection always compares as changed. The fix
is to compute derived values once at write time and hand out the cached value.
Returns `[{:fragment id :topic t :type class}]`, empty when everything is stable.
Scalars are skipped: a reader returning a fresh `Long` or `String` is fine, because
`same?` falls back to `=` for those.
Reported once per topic, against the **innermost** fragment that reads it. Topics
bubble up to containing fragments, so a naive walk reports the same reader once per
ancestor — three copies of one problem for a three-deep tree, which buries the
signal it exists to raise.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |