Skip to content

examples: idiomatic pass — hackernews twins, notes, occlusion specs - #3717

Open
ryansolid wants to merge 14 commits into
nextfrom
examples/hackernews
Open

ryansolid wants to merge 14 commits into
nextfrom
examples/hackernews

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

The idiomatic pass over the examples in documentation/plans/examples-grid-plan.md, one commit per example. Each example takes the authoring layout and the shape a user would write by hand. A binding slot appears only where a client-owned position sits inside server markup, so the pass also records where they turn out not to fit. Framework bugs found along the way are reproduced in specs and fixed in their own PRs with changesets. This PR stays examples, docs and tests.

Rebased onto next, which includes #3910, so the hackernews routes' respond() calls type-check and unwrap.

So far: hackernews and hackernews-spa, then notes together with the end-to-end occlusion specs, then todos, then todos-server, then a router bump across the examples. More examples will land here as commits.

hackernews and hackernews-spa

Both twins are in the authoring layout, every hackernews screen is a server route, and the thread has no client code.

The collapse is native <details>. It only hides replies, which is what <details> does, so both twins render the same <details> with a CSS label swap. This PR first built the collapse as a binding slot (a fill per comment binding the class, handler, label and display). Review replaced it: that was a heavier way to do what the browser already does. The SPA's Toggle component is deleted, and the toggle styles move to the <summary> so font sizes don't compound with nesting.

Every hackernews screen is a server route: export default serverRouteComponent(getStory). The router makes the call from the match on navigation and on link hover, so there are no route components and no preload exports. The feeds are one /:type? route, filtered to the five feed names, with page coming from a hand-written search schema. The SPA twin uses the same route pattern and filter, and parses ?page itself. The URLs haven't changed.

Every screen is a cacheable GET. The server twin's routes are GET reads that return respond(View, cacheable), where src/server/cache.ts sets cache-control: public, max-age=60 (HN's data is public, and a minute stale is fine). That is what makes a hover count: the hover's preload response sits in the browser's HTTP cache when the click makes the same GET. The SPA twin keeps the equivalent in memory through query.

No navigation dim. An earlier revision dimmed the page while routing (useIsRouting()). Both twins now drop it: the current page simply stays up until the next is ready.

The nav is outside Loading. It does no I/O, so the shell waits on it at no cost. It renders inline before the content, and navigation leaves it alone.

Router → 2.0.0-next.38 in every example that uses it (hackernews, hackernews-spa, notes, room, todos-server). next.38 makes link preloading opt-in, so each of those routers passes preloadLinks: intentPreload() and keeps the hover, focus, and touch preload it had. Along the way: next.30 fixed server routes with no search schema being sent as { params, search: undefined }, which the JSON argument check rejected, so client navigation to the user page never happened (solidjs/solid-router#615). next.34 and next.35 carry the form fixes todos-server needs (below). The lockfile diff touches only the router.

Both twins take the authoring layout:

  • Each route file holds its screen: the query with its server function inline (const getStory = query(async ({ params }) => { "use server"; … }, "story")). The thread's file also holds the recursive Comment.
  • src/server/hn.ts (and the capture) is a plain module that begins import "server-only". It's byte-identical in both twins.
  • lib/, api.ts, views.tsx and both components/toggle.tsx files are deleted. The SPA keeps components/ for its templates.
  • Diffing the twins' routes/story.tsx shows the thesis: the same getStory, returning markup in one and data in the other.

Example-local fixes:

  • CommentDefinition gains the id the data carries.
  • StoryDefinition.id is number, which is what the capture and the live API both return.
  • The bundle check greps the client JavaScript. app.css contains the class names, so the old instruction to grep all of dist/client/ could never come back empty.
  • Both vite-env.d.ts files reference @solidjs/vite-plugin/boundary-modules: TypeScript 6 rejects the undeclared import "server-only" (TS2882). The plan records this for later examples.

READMEs describe server components, server routes, the native collapse and the navigation dim. Both twins name their coordinate on the grid. The server twin's README no longer implies only the router ships: the runtime that mounts server components ships too, and the server twin's boot JavaScript is larger than the SPA's: 56.1KB against 37.0KB minified and brotli-compressed (63.9KB against 41.5KB gzip), counting the client entry and its static imports. The five lazily loaded chunks are the same size in both twins.

A spec pins the class-array form at a binding position (class={["toggle", { open: t.open }]}) on both server faces. There was no test for it before. It was written for the first collapse and still covers the runtime independently.

notes

React's server-components demo, moved to the authoring layout and toward React's own shape.

The shell is a client component, as React's App.js is. Only the sidebar list and the note preview are server components. The server shell had existed only to host the search field as a binding slot. With the shell on the client, the search field is a plain client component, as it is in the demo. This is the pass's first finding about binding slots: one needs a position inside server markup, and here the only such positions are the per-note ones SidebarNoteContent already owns. The binding-slot demos are now todos-server and chat's copy button.

The editor's data is a plain query. getNoteEdit returns the note. Before, a server component served as a data loader, filling the editor slot with raw text.

The excerpt mounts only while expanded, as in the demo. A collapsed excerpt is never placed, so the server sends it once, as an sc:region record rather than as hidden markup, and expanding it mounts from that record with no request.

The layout matches the fullstack template. router.tsx holds the route table, the root preload and getNoteList; app.tsx is the shell; server-config.ts passes that Router to the single-flight collector. The collector takes a router instance because not every app uses Solid Router, so each router template does this wiring itself.

Two differences from the demo, fixed: the list is newest first (the demo's order by id desc), and the excerpt is the rendered markdown as plain text rather than raw markdown. The excerpt renders on the server, so marked stays in the lazy editor chunk only.

Files: server/db.ts (was lib/db.ts, now import "server-only"), and routes/note.tsx holds the note's queries. Deleted: lib/api.ts, routes.ts, server/App.tsx, server/Note.tsx, server/NoteList.tsx, server/actions.ts and components/searchField.ts. The save and delete actions live in NoteEditor.tsx, which posts them. The README is rewritten around the file map against the React demo.

todos

The SPA control, twin of todos-server. It has no server side, so the authoring layout doesn't apply, and the code was already right.

  • README: opens with the example's place on the grid (the client-owned, request/response corner beside hackernews-spa), its twin, and the planned board example it's the baseline for. It points at todos.ts as the file to read.
  • The todos.ts header keeps its instruction to read the file for its layering rather than as a list of React-to-Solid renames. It exists because agents reading the example missed the layering, but it's no longer phrased as a rebuke.
  • Cleanups: the error map moves from module scope into createTodos, so each instance has its own. The unused TodoActions type goes, and the row checkbox uses onChange like toggle-all.
  • Vite 7 → 8, as in the other grid examples. The lockfile diff touches only todos' entry.

todos-server

The server-components twin of todos, rebuilt around forms.

  • Every control is a form that works without JavaScript. .with() binds a row's id, and the server answers the page it was posted from in one round trip.
  • Intent shows through aria-busy, not an optimistic store. While a submission is in flight the router marks its form aria-busy, and app.css shows the request from that alone: a pressed toggle, a dimmed row, and (through :has()) every row toggle-all or clear-completed touches. A mark waiting over two seconds pulses. The list has no client code; the SPA twin keeps the predicting version.
  • Failures are answers the server words. Actions return { error } with the message, read through useSubmissions (the no-JS path reads the same answers from the router's flash cookie). The route lists them under the list with a retry; a later success that answers the same question (a delete, a mark-all) dismisses them.
  • The README names the render-mode condition for no-JS: streamed content that settles after the first flush needs a script, so an app that must work without JavaScript renders in the vite plugin's async mode.
  • Dependencies: router next.34, then next.35 (actions settle at their transition's commit, so aria-busy releases when the result is on screen); @solidjs/vite-plugin 3.0.0-next.49 in all eleven examples (effect, migrating-element, rendering and sierpinski move to vite ^8, which it requires).

Mounts through dynamicComponent

The server-component mounts in chat, notes, room and todos-server use dynamicComponent, the documented mount for a server component, instead of dynamic.

Occlusion specs

The single-copy claim for content a client component places conditionally had no end-to-end test. The early HN demo showed it live with deep replies collapsed by default (b3f48999e), and 63cd06688 dropped it. The only spec fed the client hand-written records. The new specs close that gap:

  • test/server/frame-occlusion-document.spec.tsx: each excerpt ships once, as markup where placed and as an sc:region record where not. When placement happens late (after an async read), both are records only.
  • test/hydration/frame-occlusion-document.spec.tsx: adopts that document with fetch stubbed to throw, then expands, collapses and re-expands. There are no warnings or requests, and each text is on screen once.

Both were shown able to fail: the server spec with placed content hidden by CSS instead of unplaced, and the client spec with the region record stripped from the document.

Docs

  • The grid plan marks hackernews, notes and todos built, records the review revisions, and closes the occlusion gap.
  • The principles doc's Q3 examples name notes' expand toggle as the markup-slot case and record why the search field stopped being an attribute-slot example. Its example map reads "expand slot".

Public API changes

None. Examples, docs, tests and example dependency versions only, so no changeset.

How did you test this change?

Latest round (cacheable GETs, no dim, router next.37, dynamicComponent): with #3910 applied locally, the hackernews dev server rendered on the server, hydrated, and navigated on the client; every route response carries cache-control: public, max-age=60. Hovering a story link and then clicking it served the click from the HTTP cache (transfer size 0, counted with a fetch wrapper on fresh page loads). This also surfaced an unbounded re-ask loop when a re-asked server component failed the same way twice (the job posts' /users/null link), fixed in #3922.

After the rebase onto next: pnpm build, then all three @solidjs/web suites (client 1115, server 1385, hydration 271, all passing). tsc --noEmit and vite build for hackernews, hackernews-spa and notes.

hackernews twins:

  • tsc --noEmit and vite build for both twins.
  • Server-only boundary: the build passes with the server-only markers, and fails with the plugin's error on a deliberate client import of ~/server/hn (reverted).
  • Client bundle: hackernews' client JS contains no item-view-comments-header, comment-children, news-item, user-view, collapse-label or nav markup. Neither twin's contains node-hnapi or the capture.
  • Markup parity: with hydration markers stripped, the twins are byte-identical on /, /new?page=2, the user page and the 1,406-comment thread (760,997 characters, 652 <details>). The server twin's pages carry no binding-slot markers. The earlier route checks (/top, /ask?page=abc falling back to page 1, and an unknown path rendering no view) passed on the same route table.
  • Browser, both twins (dev servers, fresh Vite cache):
    • The collapse works on page load and after client navigation. A collapsed element goes from 9,180px to 38px, and checkVisibility() reports the replies hidden.
    • Collapsing a child under an open parent shows only the child's collapsed label, and the child stays collapsed when the parent is reopened. The accessibility tree names each summary by its visible label.
    • Navigation matrix: feed → user, thread → user, user → feed, user → /, feed → thread, thread → feed, and pagination.
      • Each hover makes the call ahead of time ({"params":{"id":"lxm"}}, {"params":{"type":"new"},"search":{"page":2}}), and the click makes no request.
      • The header element persists, and nothing is logged.
    • Dim: both twins render class="page" with no dim in the first render. On a cold click, routing is set within about 8ms, the old page stays up with no Loading... fallback, and the dim clears as the new view lands (394ms leaving the big thread, about 200ms for a user page). A hovered navigation lands in about 20ms without dimming.
  • Room: typechecks and builds, and renders both routes on the server. Its HTTPS dev server uses a self-signed certificate the test browser refuses, so its client wasn't driven.

notes:

  • tsc --noEmit and vite build. The boot JavaScript is 62.0KB brotli (62.8KB before). marked is only in the lazy editor chunk, and date-fns and unstorage are in no client chunk.
  • Document: each collapsed excerpt appears once, inside its sc:region record, and nowhere as markup.
  • Browser, production build, with every fetch and console warning and error recorded (none logged):
    • Expand, collapse and re-expand make no requests and re-insert the same DOM node. The same holds for a note that arrived in a mutation response.
    • Hovering a note link fetches the note, and the click reuses that request. Hovering Edit loads the editor chunk and the note's data, and the click then makes no request. Expanded notes stay expanded across navigation.
    • Search: one list request per change, the spinner turns on and then off, and expanded notes stay expanded. The note links keep the filter, and a deep link with ?searchText= fills the box and filters the list.
    • A draft in the editor survives the sidebar refetching around it.
    • Save, create and delete are one POST each, landing on the note, the new note and /. On a rename, the sidebar item keeps its node and expanded state and flashes. A new note goes to the top, and the notes below move down with their nodes and expanded state.

todos:

  • tsc --noEmit and vite build.
  • Browser, preview build, with the mock API's failures forced on and off and console warnings and errors recorded (none logged): adds show pending, then settle. A toggle is optimistic, and a failed toggle reverts with a retry button whose retry succeeds. All three filters work, as do toggle-all and clear-completed. A failed add stays visible with a retry that saves it.

@changeset-bot

changeset-bot Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3275b4f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@socket-security

socket-security Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​solidjs/​vite-plugin@​3.0.0-next.499810010097100
Updatednpm/​@​solidjs/​router@​2.0.0-next.29 ⏵ 2.0.0-next.38100100100 +197 +1100

View full report

@ryansolid ryansolid changed the title examples: hackernews collapse as a binding slot, twins in the authoring layout examples: hackernews twins in the authoring layout, server routes, native collapse Sep 30, 2026
@ryansolid
ryansolid changed the base branch from feat/text-positions to next September 30, 2026 19:02
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.44 KB 0 B 0 B +23 B 7.45 KB ✅
signals: + createStore 14.70 KB 0 B 0 B +13 B 14.71 KB ✅
signals: + isPending/latest 9.73 KB 0 B 0 B +21 B 9.73 KB ✅
app: render + one signal (the simple-app floor) 9.94 KB 0 B 0 B +17 B 9.94 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.91 KB 0 B 0 B +22 B 17.93 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.27 KB 0 B 0 B 0 B 29.27 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.97 KB 0 B 0 B +5 B 12.98 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.56 KB 0 B 0 B +22 B 14.56 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.88 KB 0 B 0 B +78 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.13 KB 0 B 0 B 0 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.42 KB 0 B 0 B +19 B 25.46 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.38 KB 0 B 0 B +18 B 31.39 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.41 KB 0 B 0 B 0 B 11.41 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.38 KB 0 B 0 B +6 B 34.33 KB ⚠️ over by 49 B, 14 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.26 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 38.08 KB 0 B 0 B +6 B 38.15 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.26 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.56 KB 0 B 0 B +6 B 35.66 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.25 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.22 KB 0 B 0 B +6 B 41.21 KB ⚠️ over by 4 B, 14 B minified headroom eager (counted): web.js 22.18 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.23 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.26 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.76 KB 0 B 0 B +6 B 41.79 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 8.29 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.51 KB 0 B 0 B +6 B 47.49 KB ⚠️ over by 17 B, 14 B minified headroom eager (counted): client.js 28.02 KB; assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.23 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 8.24 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 49 B; minified 106,710 B vs 106,704 B recorded with the cap (+6 B) — 14 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 4 B; minified 124,439 B vs 124,433 B recorded with the cap (+6 B) — 14 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 17 B; minified 144,130 B vs 144,124 B recorded with the cap (+6 B) — 14 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Sep 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37059712468

Warning

No base build found for commit 1b347c7 on fix/frames-refetch-commit.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 75.991%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.82 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 195 untouched benchmarks


Comparing examples/hackernews (3275b4f) with next (358159c)

Open in CodSpeed

@ryansolid ryansolid closed this Sep 30, 2026
@ryansolid
ryansolid deleted the examples/hackernews branch September 30, 2026 21:33
@ryansolid
ryansolid restored the examples/hackernews branch September 30, 2026 21:33
@ryansolid ryansolid reopened this Sep 30, 2026
@ryansolid ryansolid changed the title examples: hackernews twins in the authoring layout, server routes, native collapse examples: idiomatic pass — hackernews twins, notes, occlusion specs Sep 30, 2026

// Nothing for the client to fill, so it is a server route like the feeds and
// the user page.
export default serverRouteComponent(getStory);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sometimes the file router injects this right? Or was this changed

serverRouteComponent

@@ -0,0 +1,37 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copyright Facebook?

@brenelz

brenelz commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

So this is the difference betweeen the hackernews and hackernews-spa?

56.1KB against 37.0KB minified and brotli-compressed

ryansolid and others added 14 commits October 9, 2026 08:24
…ng layout

The thread's collapse was a `Toggle` client component wrapping a template
slot. It is now a binding slot on server markup: the recursive `Comment` is
a server component, and each comment with replies calls
`props.toggle({ $key: c.id })` and binds the toggle's `open` class name,
its `onClick`, its label (a text position) and the replies' `display`. The
fill is the SPA twin's `Toggle` without its markup: a signal in the body,
getters over it, a plain handler. No client components remain.

Both twins take the authoring layout: each route file holds its query with
the server function inline (markup in one twin, data in the other), and
`src/server/hn.ts` is a plain module behind `import "server-only"`, with
the boundary types referenced from `vite-env.d.ts` (TypeScript 6 rejects
the undeclared side-effect import). `lib/`, `api.ts`, `views.tsx` and
`components/toggle.tsx` go. `CommentDefinition` gains `id` and
`StoryDefinition.id` is a number, as the data carries.

The READMEs lead with the collapse, name their coordinates, and correct the
bundle check (grep the client JavaScript; `app.css` carries both class
names). A server spec pins the class-array form at a binding position on
both faces.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The feeds and the user page have no client half, so they become server
routes: `serverRouteComponent(query(...))`, with the router making the
call from the match on navigation and on link hover, so they need no route
component and no `preload`. The feeds are one `/:type?` route filtered to
the five feed names, with `page` read through a hand-written search schema.
The story route keeps its component because of the collapse fill. The nav
leaves the `Loading` boundary, since it does no I/O and the shell can wait
on it. The SPA twin uses the same route pattern and filter, and parses
`?page` itself.

Router 2.0.0-next.29 sent a schema-less server route's args as
`{ params, search: undefined }`. The JSON argument check rejects that, so
client navigation to the user page silently never happened. next.30
fixed it (#615), so every example that uses the router moves to next.31.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The collapse only hides replies, which is what <details> does natively, so
the binding slot was a heavier way to do what the browser already does.
Both twins now render the same <details> with a CSS label swap: the server
twin's thread has no client code, its story route becomes a server route
like the feeds and user page, and the SPA's Toggle component is deleted.
The toggle styles move to the <summary> so font sizes don't compound with
nesting. Markup stays byte-identical between the twins.

Both roots dim the page while a navigation is pending (`useIsRouting()`),
with a short delay so a navigation that hover preloading already made fast
never flickers. The old page stays up instead of going blank.

The plan records the revision, and that the occlusion proof (content a
client component doesn't place ships once, as a record) has had no demo or
end-to-end test since the early HN demo's deep-collapsed replies were
dropped.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The notes demo moves to the authoring layout and toward the React
original. The shell is a client component, as App.js is; only the
sidebar list and the note preview are server components. The server
shell had existed only to host the search field as a binding slot, and
with the shell on the client the field is a plain client component. The
editor's data is a plain query (getNoteEdit) instead of a server
component used as a data loader. router.tsx holds the routes, the root
preload and getNoteList, and server-config.ts passes that Router to the
flight collector, as in the fullstack template.

The excerpt now mounts only while expanded, as in the demo, so a
collapsed excerpt ships once as an sc:region record and expanding it
makes no request. The list is newest first and the excerpt is the
rendered markdown as plain text, both matching the demo; the excerpt
renders on the server, so marked stays in the lazy editor chunk only.

The occlusion specs prove the single-copy claim end to end. The server
spec checks each excerpt ships once (markup where placed, a record where
not) and that late placement is locked to records. The hydration spec
adopts the document with the network stubbed to throw, then expands,
collapses and re-expands, with each text on screen once.

The README, the grid plan and the principles doc are updated: the
notes search field is no longer a binding-slot example, and the
hackernews occlusion gap is closed.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The README opens with the example's place on the grid: the client-owned,
request/response corner beside hackernews-spa, twin of todos-server, and
the baseline for the planned board example. It points at todos.ts as the
file to read.

The todos.ts header keeps its instruction to read the file for its
layering rather than as a list of React-to-Solid renames, but drops the
rebuke. The error map moves from module scope into createTodos, so each
instance has its own; the unused TodoActions type goes; the row checkbox
uses onChange like toggle-all. Vite 7 -> 8, as in the other grid
examples. The grid plan marks todos built.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ite-plugin next.47

todos-server rebuilt around forms: every control is a form that works
without JavaScript (`.with()` binds a row's id), and the server answers
the page it was posted from in one round trip. Two binding slots, `row`
and `list`, add back the optimism a form cannot express (toggle-all,
clear-completed, the live count) from one intent store the actions'
onSubmit hooks write. Failures are returned `{ error }` values read
through useSubmissions; the no-JS path reads the same answers from the
router's flash cookie. The README is rewritten around that shape.

Dependencies across the examples:
- @solidjs/router 2.0.0-next.34 in notes, room, hackernews,
  hackernews-spa and todos-server (server-rendered `.with()` forms find
  the registered action; the flash decode runs once per request).
- @solidjs/vite-plugin 3.0.0-next.47 in all eleven, so they compile with
  the workspace compiler and babel plugin; next.47 is also the first to
  inject the flash-cookie secret. effect, migrating-element, rendering and
  sierpinski move to vite ^8, which next.47 requires (transformWithOxc).

The plan's verification note records V1-V4 holding and the two router
fixes they needed.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Actions now settle at the commit of their transition (#649): a form's
aria-busy, the onSettled hooks and the submission record release when the
result is on screen, not when the action body returns. Busy state is keyed
by the form's action URL, so it survives a server-component morph. Also
picks up the link-state fixes (aria-current compares the query, base path
on a segment boundary, non-ASCII paths).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…tic store

The list no longer predicts outcomes. While a form's submission is in
flight the router marks it aria-busy, and app.css shows the request from
that alone: a pressed toggle, a dimmed row, and through :has() every row
toggle-all or clear-completed touches. A mark waiting over two seconds
pulses. The server's answer replaces the mark; the SPA twin keeps the
predicting version.

Failures are answers the server words (it knows the todo's title): the
actions return { error } with the message, and the route lists them under
the list with a retry, cleared when the next attempt at the same thing
settles. The fills, the binding-slot bindings and the optimistic store are
gone; the list has no client code.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A failed toggle's error stayed up after mark-all completed the todo, or
after the todo was deleted. A settled attempt already replaced earlier
answers to the same question within one action; now a successful delete
also dismisses that todo's toggle errors, and a successful mark-all
dismisses every row's.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Without JavaScript the page has to arrive whole: streamed content that
settles after the first flush needs a script to swap in. The example's
reads land before the flush, which is timing, not a guarantee; an app
that must work without JavaScript renders in the vite plugin's async
mode. Also brings the toggle snippet in line with the markup
(aria-pressed).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…onent

Every example moves to @solidjs/router 2.0.0-next.37, and the server
component mounts in chat, notes, room and todos-server use
dynamicComponent, the documented mount for a server component.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The server-components twin's routes are GET reads that answer through
respond(View, cacheable): a minute of public HTTP caching, so a link
hover's preload is the response the click then reads from the browser's
cache. Both twins drop the routing dim: the current page stays up until
the next is ready.

Depends on respond() being typed as its value and envelopes unwrapping
on the direct leg (#3910).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Link preloading is opt-in in next.38, so each example's router passes
intentPreload() and keeps the hover, focus, and touch preload it had.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the examples/hackernews branch from e15f69a to 3275b4f Compare October 9, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants