Skip to content

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

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

ryansolid wants to merge 5 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 now that #3713 and #3714 have merged.

So far: hackernews and hackernews-spa (three commits), then notes together with the end-to-end occlusion specs (one commit), then todos (one commit). 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.

Navigation dims instead of blanking. Both roots put the routed content in <div class={["page", { routing: useIsRouting() }]}>. A pending navigation keeps the current page up at reduced opacity until the next one lands. A 150ms delay means a navigation that hover preloading already made fast never flickers.

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.31 in every example that uses it (hackernews, hackernews-spa, notes, room). next.29 sent a server route with no search schema as { params, search: undefined }. The JSON argument check rejects that, so client navigation to the user page quietly never happened. next.30 fixed it (solidjs/solid-router#615). 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.

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?

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: b4507f2

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
Updatednpm/​@​solidjs/​router@​2.0.0-next.29 ⏵ 2.0.0-next.3110010010097 +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

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 9.51 KB 0 B 9.51 KB ✅
signals: + createStore 16.85 KB 0 B 16.85 KB ✅
signals: + isPending/latest 12.16 KB 0 B 12.16 KB ✅
app: render + one signal (the simple-app floor) 11.97 KB 0 B 12.05 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.66 KB 0 B 19.69 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.79 KB 0 B 30.79 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 14.90 KB 0 B 14.94 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 16.42 KB 0 B 16.48 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.65 KB 0 B 30.71 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.96 KB 0 B 12.98 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 46.19 KB 0 B 46.20 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 50.44 KB 0 B 50.45 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.37 KB 0 B 20.38 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps 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).

@coveralls

coveralls commented Sep 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 36783491364

Coverage remained the same at 75.991%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


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.78 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing examples/hackernews (b4507f2) with next (309b087)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

ryansolid and others added 4 commits September 30, 2026 14:26
…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>
@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
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>

// 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

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