You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
blog/optimistic-ui-without-boilerplate.md is a fifth copy of the optimistic() declarative snippet, and it is the last one still teaching the shape #1203 removed everywhere else.
At L68-72 its reducer mints the row inline with a hardcoded temp id:
and L81 calls this.optimisticTodos.add(title, promise) with the bare-string payload.
optimistic()'s reducer runs on EVERY .value read, not once per .add() (packages/core/src/optimistic.jsget value() re-folds the whole queue from source()). A hardcoded 'tmp' is stable per read, so it has no per-render churn, but two adds in flight both produce a row with id: 'tmp'. That breaks any consumer treating the id as unique: a repeat(todos, t => t.id, ...) keyed list, a @click handler capturing the id, an aria-activedescendant, a test selector. Queue stacking and per-entry release are NOT affected (add() keys its entry on an internal opt-${n}), so the damage is strictly downstream of rendering.
#1203 converged the four canonical copies on the payload-carried temp id: AGENTS.md, .agents/skills/webjs/references/optimistic-ui.md, packages/core/src/optimistic.js's JSDoc, and website/app/docs/client-router/page.ts. All four now read id: add.tempId. This post was scoped out of that change because it is published prose rather than a reference doc, and because the fix is not confined to the snippet (see below).
Decision: Port #1241's canonical form into the post in its untyped JavaScript variant, the one website/app/docs/client-router/page.ts L71-84 and packages/core/src/optimistic.js L77-90 carry, and copy that four-line reducer comment across verbatim. The post's snippets are untyped JS throughout (prop(Array), no annotated parameters), so the TypeScript-annotated AGENTS.md / skill variant would be the only typed line in the post; the untyped canonical variant is byte-identical in every load-bearing line and reads native here. On the editorial half, narrow the payoff claim rather than drop it: #1241 already settled the house phrasing when it rewrote the same beat in AGENTS.md and the skill reference to "no reconciling a temp id against the real one". What optimistic() removes is the temp-id BOOKKEEPING (the previous-state cache, the try-catch, mapping the temp row back to the server row), not the id, which is one line in the handler. The post keeps its strongest line and stops contradicting the code directly above it.
Rejected:
Dropping the "No temp IDs" beat entirely: throws away the post's payoff sentence when a truthful narrower version already exists in the codebase.
Copying the TypeScript-annotated AGENTS.md / skill snippet verbatim: would be the only annotated code in an untyped post.
Adding a test that pins the markdown snippet: no such test exists for any of the five copies, and packages/core/test/signals/optimistic-declarative.test.js says so in its own header comment. Pinning prose text is not the layer this belongs at.
Steps
All edits are in blog/optimistic-ui-without-boilerplate.md. Nothing else in the repo changes.
1. Front matter, L5 description. The description is the SEO meta and repeats the claim. Replace no temporary ID bookkeeping with no temp-id reconciliation, so the line reads:
description: "WebJs now ships a declarative optimistic() API with full React 19 useOptimistic parity for Web Components. No try-catch, no manual state caching, no temp-id reconciliation. Just add, await, and reconcile."
2. The main snippet, the fenced block at L63-93. Replace the whole block with this. The reducer comment is copied verbatim from website/app/docs/client-router/page.ts L73-76.
classTodoListextendsWebComponent({todos: prop(Array)}){constructor(){super();this.todos=[];this.optimisticTodos=optimistic(this,{source: ()=>this.todos,// Pure: the row is derived from the payload, nothing is minted here.// The .value getter re-folds the queue on EVERY read, so a minted id// would differ per render, and a hardcoded 'tmp' would collide across// two concurrent adds. The temp id is minted once in the handler.update: (state,add)=>[
...state,{id: add.tempId,title: add.title,completed: false,pending: true},],});}asynchandleSubmit(e){e.preventDefault();consttitle=getTitle(e);consttempId=crypto.randomUUID();constpromise=createTodo({ title });this.optimisticTodos.add({ tempId, title },promise);constresult=awaitpromise;if(result.success&&result.data){this.todos=[...this.todos,result.data];}}render(){returnhtml`<ul>${this.optimisticTodos.value.map(t=>html`<liclass=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;}}
3. The paragraph at L95, which still describes the bare-string payload twice. Replace the whole paragraph with:
The difference is structural. Instead of manually caching and reverting, you declare a pure reducer that builds the optimistic row out of a payload, then call .add({ tempId, title }, promise) and let the wrapper handle the rest. The optimistic update appears immediately. When the promise settles, it disappears. You update this.todos on success and the source of truth reconciles naturally.
4. The payoff line at L97. Replace the whole paragraph with:
No try-catch. No previous-state cache. No reconciling a temp id against the real one. Minting the id is a single line in the handler, and everything that used to surround it is gone: nothing tracks the temp row afterwards, because the overlay drops whole when the promise settles and the authoritative row arrives from result.data. The reducer is the only place the optimistic shape lives, so there is one source of truth for what an optimistic item looks like.
5. The .value paragraph at L103, in "How it works under the hood". It already explains that the value is computed on every read, which is exactly where the purity rule belongs. Append these two sentences to the end of that paragraph:
Because the fold re-runs on every read, the reducer has to be pure. Anything it mints is minted again on each render, so a crypto.randomUUID() inside update would hand the pending row a fresh id every time and a keyed list would tear that row down and rebuild it on each update, losing focus and any in-progress transition. Mint the id once in the handler, pass it in the payload, and it stays stable for the life of the row.
6. The auto-release snippet at L131-135. Replace with:
consttempId=crypto.randomUUID();constpromise=createTodo({ title });this.optimisticTodos.add({ tempId, title },promise);// The optimistic item disappears when promise settles (resolve or reject).
7. The manual-release snippet at L139-147, which the original issue notes missed. Its .add(title) is the same bare-string payload. Replace the first line only, leaving the try/finally body untouched:
constrelease=this.optimisticTodos.add({tempId: crypto.randomUUID(), title });
The prose at L137 already says .add(payload), so it needs no change.
8. L174, "The new API reduces it to three lines: declare the reducer, call .add(), reconcile on success." With the mint in the handler it is no longer three lines, but it is still three steps, and the three named steps are what the sentence is actually counting. Change reduces it to three lines to reduces it to three steps, leaving the rest of the sentence as is.
9. The closing sentence at L176. Replace The boilerplate of temp IDs, try-catch, and manual reconciliation is gone. with The boilerplate of state caching, try-catch, and temp-id reconciliation is gone.
10. Leave these alone, each checked and each correct as written:
L12, "you generate temporary IDs", describes the manual pattern.
L20-55, the six-step old-way walkthrough, including its tmp-${crypto.randomUUID()}.
L164 and L166, the honest-trade section.
L172, "The old pattern required understanding temp IDs, state caching, try-catch reconciliation, and concurrent update handling", which is a statement about the OLD pattern and stays true.
Tests
N/A, because this is a prose and example change with no behaviour change.packages/core/test/signals/optimistic-declarative.test.js L194-260 already pins every runtime fact the corrected snippet leans on: .value is stable across repeated reads for one queued update, a minting reducer yields a different id per read (the bug, encoded deliberately), and two concurrent adds get distinct payload-derived ids. docs: make the canonical optimistic() reducer snippets pure #1241 added those and proved the counterfactual there.
No test pins any doc snippet. That file's own header comment states it: "the doc snippets themselves have no test coverage, so a doc revert would not red these." That is true of all five copies, and it is worth knowing rather than working around.
Gate instead of a test. Render the post through the real page module and confirm it still renders: call the default export of website/app/blog/[slug]/page.ts as BlogPost({ params: { slug: 'optimistic-ui-without-boilerplate' } }) through renderToString from @webjsdev/core/server, run from website/ so the #app/... alias resolves (the pattern website/test/ssr/nav-and-routes.test.ts uses), and assert non-empty output containing add.tempId. Throwaway script, not a committed test.
Counterfactual: none, and none is owed. Reverting the fix alone reds nothing, because no test reads the markdown. The nearest existing red is the minting-reducer test above, which pins the runtime fact the doc describes, not the doc.
Doc surfaces
Full grep over the repo for optimisticTodos.add, add(title, and tempId, excluding node_modules:
Target, the only file that changes:blog/optimistic-ui-without-boilerplate.md.
Already correct as of docs: make the canonical optimistic() reducer snippets pure #1241, MUST NOT be re-edited:AGENTS.md L384-403, .agents/skills/webjs/references/optimistic-ui.md L38-58, packages/core/src/optimistic.js L79-90 (JSDoc), website/app/docs/client-router/page.ts L71-84.
Already correct before docs: make the canonical optimistic() reducer snippets pure #1241, the reference the others were copied from:packages/cli/templates/gallery/modules/todo/components/todo-app.ts L23-52. Its reducer is the pure discriminated-union form, { id: op.tempId, title: op.title, ... }, with crypto.randomUUID() minted at L50 in the handler. Verified against the current checkout, the gallery is NOT stale and is out of scope.
The post is rendered from the markdown by website/app/blog/[slug]/page.ts, so there is no separate page, and no scaffold, MCP, editor-plugin, README, or changelog surface carries this snippet.
Implementation notes (for the implementing agent)
Where to edit:blog/optimistic-ui-without-boilerplate.md only. Line anchors below are verified against the current checkout at 928e409c and supersede the ones this issue originally carried, which were off by a line or two and missed three sites.
L5, the front-matter description.
L63-93, the fenced WebJs snippet. The optimistic() call is L68-74 and the update reducer is L70-73. The .add(title, promise) is L81.
L95, the paragraph explaining the reducer, which describes the bare-string payload twice ("when a title comes in, append an optimistic item", "you call .add(title, promise)").
L97, the "No temp IDs" payoff line.
L103, the .value paragraph in "How it works under the hood", where the purity note lands.
L131-135, the auto-release snippet, with .add(title, promise) at L133.
L139-147, the manual-release snippet, with .add(title) at L140. Not in the original notes.
L174, "reduces it to three lines". Not in the original notes.
L176, the closing paragraph.
Reference implementations, copy from, do not change:packages/cli/templates/gallery/modules/todo/components/todo-app.ts L23-52 for the runnable shape, and website/app/docs/client-router/page.ts L71-84 for the untyped snippet plus the exact reducer comment this plan reuses.
Landmines:
Invoke webjs-blog-write before touching the prose. It carries the house voice and the hard rules for this surface: no em-dashes, no internal PR or issue numbers in published text, no process tells. A straight technical edit will violate them.
Do not renumber or restructure the post's six-step "old way" walkthrough at L20-55. That section deliberately shows the painful manual pattern, temp ids included, and it is the setup the payoff pays off. The temp id THERE is correct and should stay; only the WebJs-side snippet and the claims about it change.
Invariant 11 applies to every added line: no em-dash, no space-surrounded hyphen or semicolon as a pause, WebJs capitalized in prose. Enforced by .claude/hooks/block-prose-punctuation.sh on new content.
Invariant 9 does not bite here. The docs page had to escape its backticks because the snippet lives inside an html template literal; this is a markdown fence, so the backticks in html`...` stay as written.
The post is rendered from the markdown by website/app/blog/[slug]/page.ts, so there is no separate page to edit.
Invariants to respect: AGENTS.md invariant 11. No behaviour change, so no webjs check rule is involved.
Acceptance criteria
The post's reducer no longer hardcodes { id: 'tmp' }; the temp id is minted in the handler and passed in the payload
Its snippet matches the untyped canonical form in website/app/docs/client-router/page.ts, reducer comment included
All three .add() call sites carry the payload form: L81, L133, and L140
The prose explaining .add(payload, promise) moves with the payload change, so no sentence still describes the bare-string payload
Problem
blog/optimistic-ui-without-boilerplate.mdis a fifth copy of theoptimistic()declarative snippet, and it is the last one still teaching the shape #1203 removed everywhere else.At L68-72 its reducer mints the row inline with a hardcoded temp id:
and L81 calls
this.optimisticTodos.add(title, promise)with the bare-string payload.optimistic()'s reducer runs on EVERY.valueread, not once per.add()(packages/core/src/optimistic.jsget value()re-folds the whole queue fromsource()). A hardcoded'tmp'is stable per read, so it has no per-render churn, but two adds in flight both produce a row withid: 'tmp'. That breaks any consumer treating the id as unique: arepeat(todos, t => t.id, ...)keyed list, a@clickhandler capturing the id, anaria-activedescendant, a test selector. Queue stacking and per-entry release are NOT affected (add()keys its entry on an internalopt-${n}), so the damage is strictly downstream of rendering.#1203 converged the four canonical copies on the payload-carried temp id:
AGENTS.md,.agents/skills/webjs/references/optimistic-ui.md,packages/core/src/optimistic.js's JSDoc, andwebsite/app/docs/client-router/page.ts. All four now readid: add.tempId. This post was scoped out of that change because it is published prose rather than a reference doc, and because the fix is not confined to the snippet (see below).Found during the review of #1241.
Implementation plan
Decision: Port #1241's canonical form into the post in its untyped JavaScript variant, the one
website/app/docs/client-router/page.tsL71-84 andpackages/core/src/optimistic.jsL77-90 carry, and copy that four-line reducer comment across verbatim. The post's snippets are untyped JS throughout (prop(Array), no annotated parameters), so the TypeScript-annotatedAGENTS.md/ skill variant would be the only typed line in the post; the untyped canonical variant is byte-identical in every load-bearing line and reads native here. On the editorial half, narrow the payoff claim rather than drop it: #1241 already settled the house phrasing when it rewrote the same beat inAGENTS.mdand the skill reference to "no reconciling a temp id against the real one". Whatoptimistic()removes is the temp-id BOOKKEEPING (the previous-state cache, the try-catch, mapping the temp row back to the server row), not the id, which is one line in the handler. The post keeps its strongest line and stops contradicting the code directly above it.Rejected:
AGENTS.md/ skill snippet verbatim: would be the only annotated code in an untyped post.createdAt: new Date()to matchAGENTS.md: the post's row shape never carried it, and it is the one impure line docs: make the canonical optimistic() reducer snippets pure #1241 tolerated with a caveat, not a thing to propagate to a fifth copy.packages/core/test/signals/optimistic-declarative.test.jssays so in its own header comment. Pinning prose text is not the layer this belongs at.Steps
All edits are in
blog/optimistic-ui-without-boilerplate.md. Nothing else in the repo changes.1. Front matter, L5
description. The description is the SEO meta and repeats the claim. Replaceno temporary ID bookkeepingwithno temp-id reconciliation, so the line reads:2. The main snippet, the fenced block at L63-93. Replace the whole block with this. The reducer comment is copied verbatim from
website/app/docs/client-router/page.tsL73-76.3. The paragraph at L95, which still describes the bare-string payload twice. Replace the whole paragraph with:
4. The payoff line at L97. Replace the whole paragraph with:
5. The
.valueparagraph at L103, in "How it works under the hood". It already explains that the value is computed on every read, which is exactly where the purity rule belongs. Append these two sentences to the end of that paragraph:6. The auto-release snippet at L131-135. Replace with:
7. The manual-release snippet at L139-147, which the original issue notes missed. Its
.add(title)is the same bare-string payload. Replace the first line only, leaving the try/finally body untouched:The prose at L137 already says
.add(payload), so it needs no change.8. L174, "The new API reduces it to three lines: declare the reducer, call
.add(), reconcile on success." With the mint in the handler it is no longer three lines, but it is still three steps, and the three named steps are what the sentence is actually counting. Changereduces it to three linestoreduces it to three steps, leaving the rest of the sentence as is.9. The closing sentence at L176. Replace
The boilerplate of temp IDs, try-catch, and manual reconciliation is gone.withThe boilerplate of state caching, try-catch, and temp-id reconciliation is gone.10. Leave these alone, each checked and each correct as written:
tmp-${crypto.randomUUID()}.Tests
packages/core/test/signals/optimistic-declarative.test.jsL194-260 already pins every runtime fact the corrected snippet leans on:.valueis stable across repeated reads for one queued update, a minting reducer yields a different id per read (the bug, encoded deliberately), and two concurrent adds get distinct payload-derived ids. docs: make the canonical optimistic() reducer snippets pure #1241 added those and proved the counterfactual there.website/app/blog/[slug]/page.tsasBlogPost({ params: { slug: 'optimistic-ui-without-boilerplate' } })throughrenderToStringfrom@webjsdev/core/server, run fromwebsite/so the#app/...alias resolves (the patternwebsite/test/ssr/nav-and-routes.test.tsuses), and assert non-empty output containingadd.tempId. Throwaway script, not a committed test.Doc surfaces
Full grep over the repo for
optimisticTodos.add,add(title, andtempId, excludingnode_modules:blog/optimistic-ui-without-boilerplate.md.AGENTS.mdL384-403,.agents/skills/webjs/references/optimistic-ui.mdL38-58,packages/core/src/optimistic.jsL79-90 (JSDoc),website/app/docs/client-router/page.tsL71-84.packages/cli/templates/gallery/modules/todo/components/todo-app.tsL23-52. Its reducer is the pure discriminated-union form,{ id: op.tempId, title: op.title, ... }, withcrypto.randomUUID()minted at L50 in the handler. Verified against the current checkout, the gallery is NOT stale and is out of scope.website/app/blog/[slug]/page.ts, so there is no separate page, and no scaffold, MCP, editor-plugin, README, or changelog surface carries this snippet.Implementation notes (for the implementing agent)
Where to edit:
blog/optimistic-ui-without-boilerplate.mdonly. Line anchors below are verified against the current checkout at928e409cand supersede the ones this issue originally carried, which were off by a line or two and missed three sites.description.optimistic()call is L68-74 and theupdatereducer is L70-73. The.add(title, promise)is L81..add(title, promise)")..valueparagraph in "How it works under the hood", where the purity note lands..add(title, promise)at L133..add(title)at L140. Not in the original notes.Reference implementations, copy from, do not change:
packages/cli/templates/gallery/modules/todo/components/todo-app.tsL23-52 for the runnable shape, andwebsite/app/docs/client-router/page.tsL71-84 for the untyped snippet plus the exact reducer comment this plan reuses.Landmines:
webjs-blog-writebefore touching the prose. It carries the house voice and the hard rules for this surface: no em-dashes, no internal PR or issue numbers in published text, no process tells. A straight technical edit will violate them.WebJscapitalized in prose. Enforced by.claude/hooks/block-prose-punctuation.shon new content.htmltemplate literal; this is a markdown fence, so the backticks inhtml`...`stay as written.website/app/blog/[slug]/page.ts, so there is no separate page to edit.Invariants to respect: AGENTS.md invariant 11. No behaviour change, so no
webjs checkrule is involved.Acceptance criteria
{ id: 'tmp' }; the temp id is minted in the handler and passed in the payloadwebsite/app/docs/client-router/page.ts, reducer comment included.add()call sites carry the payload form: L81, L133, and L140.add(payload, promise)moves with the payload change, so no sentence still describes the bare-string payloadAGENTS.mdand the skill reference, so the post does not contradict the code it recommendsdescriptionno longer claims temp-id bookkeeping is goneblog/optimistic-ui-without-boilerplate.mdis touched/blog/optimistic-ui-without-boilerplatestill renders, non-empty, with the corrected snippet in the output