Loading `on` should behave as a keyed <Show> around the boundary; a boundary mounted under a hold must not be born held (A29)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 36.1k
- Forks
- 1.1k
- Avg merge
- 9h 18m
- Merged PRs (30d)
- 195
Description
Follow-up to #3524 / #3529 (closed by design) and the Discord thread with @Mizulu and @GabbeV. Captures the model, a ruling, an inconsistency found while testing it, and the work that falls out.
The model (why #3529 is by design for a committed key)
Every reader of a pending value holds the write that made it pending. A <Loading> without on is such a reader: once it has content, a refetch keeps that content visible and holds the write until the data lands. on is a key — key on React's Suspense, not a reset trigger. While the key is unchanged the boundary holds like any reader; when a write changes the key, the boundary stops holding that write and lands in its fallback as part of that write's commit. The fallback belongs to the committed frame (Gabriel's example: "Loading comments for product B" must not render beside product A's info).
So in #3529 — A keyed on count, B a plain <Loading> reading the same async memo — B holds the count write, A's key change is part of that write, and the commit waits for B. By the time it lands the data is there and no fallback is seen. Not a bug; A's key is committed state.
Docs gap: the on docblock and cheatsheet describe it as "re-show fallback on key changes", which reads as a trigger. That produced #3524, #3529 and the thread.
Gabriel's argument, and the ruling
Gabriel: on should be semantically equivalent to a keyed <Show> wrapping the boundary, minus the remount — whatever the key expression reads falls out of that. If the key reads latest(count), it changes ahead of the commit, the boundary swaps, and a fresh boundary has nothing to preserve, so it shows its fallback now. No detection of latest needed; the key is an ordinary reactive expression.
@ryansolid's ruling: A29 ("born held") is wrong for boundaries. Born held is right for a plain memo/effect created mid-hold that reads a held value — it would otherwise tear. A boundary is the exception by definition: its job is to catch pending reads under it rather than let them hold. A Loading mounted while a write is held shows its fallback; the hold stays with readers that have content to keep.
Inconsistency found: <Show keyed> disagrees with itself
rc.9 head (ce17c2981), jsdom. One signal, one 1s async memo data, A under test, B a plain <Loading> reading data(). Snapshot after setCount(...), flush(), one microtask, flush():
| A | mid-flight |
|---|---|
<Show keyed when={latest(count)}><Loading>{data()}</Loading></Show> (static children) |
A: Loading A — reveals early |
<Show keyed when={latest(count)}>{() => <Loading>{data()}</Loading>}</Show> (function child) |
held |
<Show keyed when={count()}> either children form |
held (correct: committed key) |
<Loading on={count()}> |
held (correct) |
<Loading on={latest(count)}> |
held — should reveal early under the ruling |
Same remount over the same optimistic key lands early or late depending on how the children are written. The only mechanical difference in Show is the function child being called under untrack (flow.ts:226), and untrack only flips the tracking flag — so the born-held bookkeeping is keying off the tracking context at creation. Root cause not yet isolated. Under the ruling the function-child path is the bug; the fix belongs in the born-held rule (exempt creation under a boundary), not in Show.
Gabriel's playground (static children, reveals early): https://s.olid.uk/id/DkA_Qgi1QLOJyuyAf8MaWw
Repro (vitest, jsdom, packages/web/test)
/** @jsxImportSource @solidjs/web @vitest-environment jsdom */
import { createSignal, createMemo, Loading, Show, latest, flush } from "solid-js";
import { render } from "../src/index.js";
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
const div = document.createElement("div");
let setCount!: (v: number) => void;
render(() => {
const [count, _set] = createSignal(1);
setCount = _set;
const data = createMemo(async () => { const v = count(); await delay(1000); return v; });
return (
<>
<p>Count: {count()}</p>
<p>A: <Show keyed when={latest(count)}>{() => <Loading fallback="Loading A">{data()}</Loading>}</Show></p>
<p>B: <Loading fallback="Loading B">{data()}</Loading></p>
</>
);
}, div);
flush(); await vi.advanceTimersByTimeAsync(1000); flush();
setCount(2); flush(); await Promise.resolve(); flush();
div.textContent; // "Count: 1A: 1B: 1" — held. Static children instead: "Count: 1A: Loading AB: 1".
Work
- Born held exempts boundaries. Isolate why the tracked vs untracked creation path differs; a boundary (
createLoadingBoundary) created while a transaction holds what it reads lands its fallback now. Pin with the matrix above: bothShowchildren forms reveal early overlatest(count), both hold overcount(). onevaluates as a keyedShowdoes. The key becomes a tracked expression (today_readOn()is aspectateread fromnotify, compared at commit); a change resets the boundary, landing wherever the change lands —on={count()}with the commit as today,on={latest(count)}ahead of it. Same tests as (1) asserton≡ keyedShowminus remount.- Docs.
Loadingdocblock (client/flow.ts),createLoadingBoundaryonparam (boundaries.ts), cheatsheet line ~658:onis a key that lands with the write;on={latest(x)}is the spelling for a placeholder ahead of the commit (deliberately renders a piece of the next frame beside the current one; the fallback should readlatest(x)too if it names what is loading). Fix the docblock exampleon={route}→on={route()}(the accessor as key never changes). - Reply on #3529 with the model and that
on={latest(count)}is the intended spelling, landing.
Not doing: a new prop, or runtime detection of latest inside on. Both rejected — the semantics fall out of (2) without either.
Related: #3528 (fixed in rc.9), #3532.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the born-held handling in client/flow.ts and boundaries.ts, then reproduce the tracked and untracked children matrix in packages/web/test using the provided jsdom example. Compare keyed Show with Loading on count() and latest(count()). Done means both children forms and on semantics match the ruling, with the listed docblock, parameter, and cheatsheet wording updated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- documentation, frontend, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100