Skip to content

Solid 2: port to solid-js 2 + @solidjs/vite-plugin start mode (SSG, router 2, meta 1.0) - #174

Open
devagrawal09 wants to merge 44 commits into
kobaltedev:mainfrom
devagrawal09:solid2
Open

devagrawal09 wants to merge 44 commits into
kobaltedev:mainfrom
devagrawal09:solid2

Conversation

@devagrawal09

@devagrawal09 devagrawal09 commented Oct 3, 2026 •

Copy link
Copy Markdown

Summary

Ports SolidBase to Solid 2 (solid-js / @solidjs/web 2.0.0-rc.16) and to @solidjs/vite-plugin start mode, which replaced SolidStart. The library, both apps (dev/, docs/), the test suite, static site generation and the docs content are all ported. Breaking major; public config/theme API and MDX authoring are kept wherever possible.

Draft because every upstream dependency is a prerelease (pinned exactly).

Pins

solid-js/@solidjs/web 2.0.0-rc.16 (the exact peer of Kobalte alpha.4), @solidjs/router 2.0.0-next.37 + filesystem-routing, @solidjs/meta 1.0.0-next.2, @solidjs/vite-plugin 3.0.0-next.47 (replaces @solidjs/start + nitro), @kobalte/core 2.0.0-alpha.4, @solid-primitives/storage 5.0.0-next.7, prerender-crawler 0.2.0.

Commits (grouped)

Group What
Port (976fca4–f0fe7fb) pins, src/, tests, apps on start mode, SSG, docs
Prerender hardening child process, base, 404s, relative links, prerender-crawler
Runtime fixes Back/Forward page-data fix, missing page module is not an error, keyed head scripts, locale selector no longer re-navigates, MDX emits class
Review follow-ups fileRoutes handling, context diagnostics, preferred-language race, font preloads/deploy docs, docs links
Static hosts per-directory 404.html so missing URLs hydrate (+ Netlify 404 rewrites)
Review follow-ups (Astra) installable package manifest, prerender result over IPC, index-only 404 dirs, router-base-aware links/locale/sidebar, ./types export + migration guide, untracked-read fixes, in-page links don't claim aria-current, trailing-slash-safe prev/next/sidebar matching
Kobalte/storage rc.16 + alpha.4 + next.7 bump, Kobalte Tabs/Accordion restored, lazy-preload workaround dropped

What changed in the library

Mechanical: solid-js/web → @solidjs/web; JSX/ComponentProps from the renderer package; jsxImportSource (tsconfig and the MDX compiler + twoslash); merge/omit; Loading; onSettled; context-is-provider; classList → class arrays; boolean aria-* → enumerated strings.

Semantic (please review):

  • createResource → async createMemo in page-data, with deferStream passed through (default true) so [...404] pages still set their status under streaming.
  • Real bug found by v2: getRawTheme() wrote setTheme() inside a computation. The signal is now seeded from the cookie.
  • Fixes default-theme: module-scope mobileLayout watcher leaks a computation and breaks hydration on viewports ≤ 1100px #152: one detached root in globals.ts instead of one per Layout.
  • @solidjs/meta 1.0 is provider-less: MetaProvider and the meta.provider prop are removed. The theme and preferred-language cookie scripts are keyed <Script>s, so hydration takes over the prerendered tag instead of adding (and re-running) a second copy.
  • Router 2: native <a> + useLinkState; useRouteMatches; page module resolved via the lazy route's preload()/moduleUrl. Page data is registered under Vite-root-relative keys; the old absolute keys never matched, which stalled Back/Forward to the hydrated route in production builds.
  • setLocale stays a plain function (as on main); <html lang> follows the current locale through an effect, so it is also right after plain links and Back/Forward. The locale Select only navigates on an actual locale change: Kobalte re-fires onChange whenever its options change, and re-navigating to the current locale without the host's trailing slash pushed a history entry and dropped Forward.
  • MDX emits HTML attribute names (elementAttributeNameCase: "html"): Solid 2's compiler no longer rewrites className to class, which had dropped every class from markdown output (Expressive Code, task lists, footnotes).
  • SolidBaseRoot sets httpStatus(404) when no route matched (Router 2 would return 200).
  • Context diagnostics restored (v2 throws on reading a context without a default).
  • resolve.dedupe for Solid/router/meta (pnpm peer variants → two contexts).
  • Tab groups are Kobalte Tabs again, and the dev preview uses Kobalte Accordion. (alpha.2's pinned @solid-primitives shifted hydration keys, hence the interim native markup). Named groups persist via makePersisted (cookie storage + messageSync over a client-only BroadcastChannel, hydrated: true, explicit dispose). Groups lacking the saved tab fall back to the first.

Routing: src/config/file-routes.ts builds on filesystem-routing, which already treats md/mdx as pages. It is wired into plugin(); fileRoutes: false opts out (solidBaseToRoute is exported for manual wiring). startConfig() returns @solidjs/vite-plugin options. A custom fileRoutes.dir is honored by prerender, sitemap and llms.

SSG: solidbase:prerender (buildApp post-hook) renders /, the markdown route index, extras and crawled links through the production handleRequest(req, { renderMode: "async" }) into dist/client. Link extraction, queueing and output writes are handled by prerender-crawler; SolidBase adds base mapping and the 404 probe. It runs in a child process: the server bundle's runtime holds handles that kept vite build from exiting. On by default (prerender: false for SSR); non-200s are logged. The 404 probe is also rendered per directory and kept where it differs (e.g. fr/, guide/, reference/), so a missing URL is served a 404 page rendered for its own locale and section and hydrates cleanly; hosts that only fall back to the root 404.html need rewrites (Netlify rules in netlify.toml, documented in the Deploy guide).

Generated assets (sitemap/robots/llms) are emitted from generateBundle instead of Nitro publicAssets.

Consumer migration (apps)

Remove entry-*.tsx and app.config.ts. vite.config.ts uses solid(solidBase.startConfig()). Add src/Document.tsx (getHtmlProps() + <HydrationScript/>). app.tsx becomes createRouter({ routes: fileRoutes(pageRoutes) }) wrapping SolidBaseRoot. Drop meta.provider. Add /// <reference types="@kobalte/solidbase/types" /> (virtual modules) and jsxImportSource: "@solidjs/web". See the rewritten Adding to Existing Project and Deploy guides.

Upstream fixes from this work

solidjs-community/solid-primitives#1069, #1070, #1071 · kobaltedev/kobalte#746 · solidjs-community/solid-primitives#1079 · solidjs/solid#4013 (fixed by #4019 in rc.15; our lazy() workaround is removed). Filed: kobaltedev/kobalte#753 (Tabs aria-labelledby), kobaltedev/kobalte#754 (Tabs RTL direction), #176 (locale picker → 404).

Verification

  • pnpm test: 139 passed / 29 files. pnpm build (tsc + lib) clean.

  • pnpm build:docs → 41 pages + 404.html, 41 sitemap URLs, robots.txt, llms.txt, 0 non-200 links. dev/ build → 15 pages + 404.html.

  • Browser: direct loads hydrate cleanly; tabs, Back/Forward, locale switch OK.

  • Netlify deploy preview (9530003, Chromium): direct loads (en/fr) with clean consoles; tabs (click, arrows/Home/End, same-page and cross-tab sync, cookie restore, fallback); real 404s with correct lang/sidebar and no hydration errors for /nope-xyz, /guide/…, /reference/…, /fr/…; Back/Forward/Back (en/fr, trailing slash kept); locale picker; theme cookie; copy page; mobile nav; code blocks (0 className, styling and copy buttons); each head script present and executed once.

  • TS/JS toggle (dev app with languageSwitcher: true): labels, cookie, reload, and only the selected language's code shown.

  • Packaging: every bare import in dist/ is declared; a fresh app outside the workspace installs the packed tarball, builds (with prerender) and runs vite dev. The unused esbuild MDX downlevel step is removed.

  • --base /docs/ build: lang, localized nav, sidebars, 404s and all internal links stay under the base; links that escape the base are reported by the prerender.

  • One aria-current="page" per page (in-page # links opt out; Router 2 otherwise marks every same-path link).

Known / follow-ups

  • data-theme="ystem" in server HTML: pre-existing (getTheme() does "system".replace("s","")), corrected client-side by the inline script.
  • @solid-mediakit/og has no Solid 2 release, so docs OG images are disabled.
  • with-solidbase CLI template is still SolidStart-based; the quickstart points to the guide.
  • The dev/ build prints Vite's configLoader: 'native' warning (vite.config.mts imports ../src/config as a directory or without an extension).
  • The crawler passes discovered links as a comma-joined hint header, so link paths that contain a literal comma aren't discovered. Dotted final URL segments are also skipped (explicit markdown seeds are still emitted).
  • Tab arrow keys follow Kobalte's locale direction, not dir="rtl" ([Tabs] Arrow-key direction follows locale, ignores dir="rtl" — intended? kobalte#754). Kobalte leaves aria-labelledby off tab panels on first render/SSR ([Tabs] Tabs.Content missing aria-labelledby on initial render and in SSR (solid2 / 2.0.0-alpha.4) kobalte#753).
  • Router 2 adds an empty data-active to every managed link and marks same-path links aria-current; in-page links set aria-current="false" explicitly to opt out.
  • A persisted tab is applied ~45 ms after hydration (the server renders the first tab).
  • Tab-group cookies are scoped to the page's directory path (unchanged from main), so a saved tab is shared within a directory rather than site-wide.
  • Netlify 404 rewrites in netlify.toml are listed per section/locale; a new docs section or locale needs a rule.
  • On French pages, prev/next links point to English paths (same as production; usePrevNext ignores the locale prefix).
  • The locale picker maps paths by prefix, so translations at different paths land on a 404 (same as production) — Locale picker navigates to 404 when the translated page lives at a different path #176.
  • Upstream nits: @solid-primitives/scroll@next loses the useWindowScrollPosition generic; unplugin-icons/types/solid targets 1.x JSX.
  • Out of scope: add basic repl support #173 (REPL — solid-repl is a Solid 1 library), solid-docs' osmium theme.

- solid-js / @solidjs/web 2.0.0-rc.13
- @solidjs/router 2.0.0-next.35 (+ filesystem-routing 0.4.0)
- @solidjs/meta 1.0.0-next.2
- @solidjs/vite-plugin 3.0.0-next.47 replaces @solidjs/start + nitro
- @kobalte/core 2.0.0-alpha.2
- @solid-primitives/* v2-targeting next prereleases
Mechanical:
- solid-js/web -> @solidjs/web; JSX/ComponentProps types from the renderer package
- jsxImportSource -> @solidjs/web (tsconfig + MDX compiler + twoslash)
- mergeProps/splitProps -> merge/omit; Suspense -> Loading; onMount -> onSettled
- Context.Provider -> context-is-provider; useCurrentMatches -> useRouteMatches
- classList -> class object/array form; aria-* booleans -> enumerated strings
- unplugin-icons: local ambient types (upstream types target solid-js 1.x JSX)

Semantic:
- createEffect split into compute/apply everywhere (10 sites)
- createResource -> async createMemo in page-data (deferStream kept as no-op prop)
- startTransition removed in locale switching (transitions are built in)
- @solidjs/meta 1.0: MetaProvider dropped; useHead script injection -> <Script>
  components (ThemeCookieScript / PreferredLanguageCookieScript) rendered by Root
- Solid Router 2: <A> -> native <a> + useLinkState; no useCurrentMatches
- globals.ts: module-level detached root fixes per-Layout computation leak (kobaltedev#152)
- startConfig() now targets @solidjs/vite-plugin start mode instead of SolidStart
- vitest projects: node (server build) + jsdom with resolve.conditions=[browser]
  for client-rendering tests; replaces 1.x hard-coded dist/*.cjs imports
- tests/helpers/solid.ts mount(): evaluate Solid 2 lazy JSX when calling
  components/providers directly (children getters no longer run eagerly)
- flush() where tests read synchronously after a write (microtask batching)
- theme.test: Script component assertion replaces useHead mock; two-arg
  createEffect mock; reactive write moved out of the owned scope
- page-data.test: useRouteMatches mock + async createMemo shim
- preview.test: esbuild strips TS, @solidjs/babel-plugin compiles JSX
  (fixes the pre-existing missing @babel/preset-typescript failure on main)

src/client/theme.ts: seed the theme signal from the cookie instead of writing
it during getRawTheme() — Solid 2 forbids reactive writes inside computations.
Library:
- src/config/file-routes.ts: solidBaseFileRoutes() on filesystem-routing (the SolidStart
  convention, router-neutral). md/mdx are pages out of the box; SolidBase only widens the
  pick to keep 57480SolidBase_page_data through route code-splitting. Wired into plugin()
  (opt out with fileRoutes: false).
- startConfig(): extensions need their leading dot for @solidjs/vite-plugin (.md/.mdx)
- page-data: resolve the page module via the lazy route component (preload/moduleUrl);
  the 1.x route.key. shape is kept as a fallback
- vite plugin: resolve.dedupe for solid-js/@solidjs/web/router/meta — Solid 2 context
  identity breaks when pnpm installs two peer-variants of @solidjs/router
- default-theme context: createLayeredContext instead of reading our own (absent)
  context — Solid 2 throws on a missing provider
- client: HttpStatusCode component (httpStatus from @solidjs/web) for MDX pages

Apps (dev/, docs/):
- vite.config: solid(solidBase.startConfig()) replaces solidStart() + nitro()
- src/Document.tsx (document shell w/ getHtmlProps + HydrationScript), src/app.tsx with
  createRouter + fileRoutes(pageRoutes) from virtual:file-routes; entry-*.tsx and
  app.config.ts removed (generated by the plugin)
- docs: @solid-mediakit/og plugin + og-image route disabled (no Solid 2 release)
- dev: ec-file.mdx examples point at Document.tsx/app.tsx; theme ComponentProps from web

Verified: vite dev serves all routes in both apps with no SSR errors (404 status works),
vite build of dev/ produces client + server bundles, vitest 128/128, tsc clean.
…; docs for the new setup

Prerender (src/config/vite-plugin/prerender.ts):
- buildApp (post) hook: finishes the environment builds (client first, then the rest —
  @solidjs/vite-plugin defers to a plugin that declares buildApp), then renders every
  markdown route from routes-index plus /, configured extras and crawled same-origin links
  through the production handleRequest() with renderMode: 'async' (head tags inlined,
  httpStatus honored) and writes dist/client/**/index.html + 404.html.
- Enabled by default (prerender: false to deploy the server bundle); options: routes,
  crawlLinks, notFound, origin. Skipped (non-200) paths are logged — which already
  surfaced five broken links in the docs content.

Generated assets (sitemap/robots/llms): emitted into the client bundle from
generateBundle instead of Nitro publicAssets; dev middleware unchanged.

Docs: guides and reference snippets rewritten for vite.config.ts + @solidjs/vite-plugin
start mode (Document.tsx, createRouter + fileRoutes, startConfig()); 'for SolidStart'
copy updated; netlify.toml publishes docs/dist/client as a static site.

Verified: dev/ -> 16 pages, docs/ -> 41 pages + 404.html + sitemap (41) + robots + llms,
titles/meta inlined, vitest 128/128, tsc clean.
@netlify

netlify Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for solidbase ready!

Name Link
🔨 Latest commit 9530003
🔍 Latest deploy log https://app.netlify.com/projects/solidbase/deploys/6ad829d887cdd1842958ec5b
😎 Deploy Preview https://deploy-preview-174--solidbase.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 81 (🔴 down 15 from production)
Accessibility: 100 (no change from production)
Best Practices: 100 (no change from production)
SEO: 100 (no change from production)
PWA: -
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Importing dist/server/server.js into Vite's process left the event loop alive after
prerendering (the production server runtime holds handles), so vite v7.3.6 building client environment for production...
✓ 0 modules transformed. never
returned. Attribution: building the environments from the buildApp hook alone exits
fine; the in-process import + render hangs.

The buildApp hook now spawns prerender-worker.mjs (node, JSON over stdio), which
imports the server bundle, renders the seed/crawl set with renderMode: 'async',
writes dist/client/**/index.html + 404.html and exits. Same output; both apps'
builds return in ~5s.
@devagrawal09

devagrawal09 commented Oct 3, 2026 •

Copy link
Copy Markdown
Author

Pushed a fix for a build hang found while verifying: vite build never exited after the prerender step because importing dist/server/server.js into Vite's process keeps the event loop alive (attribution: building the environments from the buildApp hook alone exits fine; the in-process import + render hangs).

The prerender now runs in a short-lived child process (src/config/vite-plugin/prerender-worker.mjs, JSON over stdio) — see e064d29. Output is identical (dev: 16 pages, docs: 41 + 404.html, sitemap/robots/llms, titles inlined); both builds exit in ~5s. Tests still 128/128.

publishConfig.exports resolve to dist/, where tsc does not emit the .mjs worker;
scripts/build.js now copies **/*.{js,mjs,css} from src so the published package can
spawn it (verified with pnpm pack --dry-run).
- Pages are requested at base + path (crawled links un-prefixed before queueing);
  output stays relative to the client out dir. Verified with base: '/sub/'.
- solidBaseToRoute is exported so apps that set fileRoutes: false and wire
  fileRoutes() themselves keep 59015SolidBase_page_data through code-splitting.
@devagrawal09

Copy link
Copy Markdown
Author

Verification pass — findings

Ran everything beyond the SSR/curl checks: frozen-lockfile install, full pnpm check, pnpm build + pnpm pack --dry-run, static-serve + vite preview of the SSG output, prerender edge cases (base, fileRoutes: false, prerender: false), and a headless-browser smoke (Playwright, local only) against vite dev and the prerendered docs/dist/client.

Fixed in this push

  • Packaging: publishConfig.exports resolve to dist/, where the .mjs prerender worker was not copied → prerender would fail for consumers of the released package. scripts/build.js now copies **/*.{js,mjs,css} (verified in the tarball).
  • Prerender honors Vite base: pages are requested at base + path and crawled links are un-prefixed before queueing.
  • fileRoutes: false users keep page data: the convention hook is exported as solidBaseToRoute (without it, $$SolidBase_page_data is tree-shaken out of code-split MDX routes — observed: 62 → 2 occurrences in the server bundle).

⚠️ Blocker found: client hydration fails on every page (Kobalte 2.0.0-alpha.2)

The browser smoke shows hydration key misses inside every Kobalte trigger (Dialog.Trigger in the Header, Popover.Trigger in the project/version selectors), followed by Hydration Mismatch → [REACTIVITY_HALTED]. After that nothing on the client reacts: SPA navigation, the theme Select, mobile nav, locale switch. SSR output is correct; it is purely a hydration failure.

Attribution with a standalone 3-case app (@solidjs/vite-plugin start mode, solid-js/@solidjs/web rc.13, no SolidBase):

Case SSR hydration keys Hydrates Click works
native <button> with <span> + <svg> consistent ✅ ✅
dynamic(() => "button") button@140 span@141 svg@143 ✅ ✅
Kobalte Dialog.Trigger server button@…060 span@…064 svg@…066, client expects span@…061 svg@…063 ❌ ❌

i.e. Kobalte's trigger consumes 3 extra hydration ids on the server before its children. This is kobaltedev/kobalte#717 (open; not fixed in alpha.2; the thread's <Dynamic> theory is ruled out above — dynamic() alone is fine, and a local patch of Polymorphic to dynamic() changed nothing).

Impact: the port is complete and correct for SSR/SSG, but the interactive client is non-functional until this is resolved. Default-theme Kobalte usage: Dialog ×3, Popover ×2, Select ×2, Collapsible, Badge, Tabs across 8 files.

Options:

  1. Wait for a Kobalte alpha with #717 fixed (upstream owner has said Polymorphic internals will be refactored for Solid 2).
  2. Find the 3 server-only id consumers in Kobalte's Button/Trigger and ship a pnpm patch (workspace-only; consumers would need the same until upstream lands).
  3. Replace the Kobalte triggers (Dialog/Popover/Select) in the default theme with native elements + small primitives — removes the alpha dependency entirely; Collapsible/Badge/Tabs could follow.

I'd lean 3 for Dialog/Popover (they are thin here) and 1/2 for Select, but that's a maintainer call.

Other observations (not blockers)

  • vite preview serves unmatched paths as 200 (preview-mode quirk; static hosts serve 404.html with 404, and httpStatus is correct under vite dev and in the SSG output).
  • No unit tests added yet for file-routes/prerender; the Playwright smoke scripts are local for now and could become a tests/e2e project if wanted.

@devagrawal09

Copy link
Copy Markdown
Author

Hydration blocker resolved — root cause is upstream and already fixed

The hydration failure reported above is solidjs/solid#3741, fixed by solidjs/solid#3757 (fix(web): spread applies attributes before children, matching ssrElement, merged to next on Oct 2, not yet in @solidjs/web@2.0.0-rc.13). Not a Kobalte bug — kobaltedev/kobalte#717 is the same thing; I left the analysis there.

Mechanism: for a component prop the compiler emits get disabled() { return memo(() => cond)() ? p.disabled : undefined }. Through dynamic()/<Dynamic> (Kobalte Polymorphic), the server read attribute getters before children while the client's spread() inserted children before its attribute effect; both draw ids from the same owner counter, so the memo and the first child swapped hydration ids. ButtonRoot has several such props → every Kobalte trigger mis-hydrated → REACTIVITY_HALTED.

Verified with @solidjs/web built from next (e44b2e4), via a temporary pnpm patch in the workspace (not pushed yet):

  • dev/ (vite dev): 0 hydration issues on /, /about, /fr/; SPA navigation; mobile-nav Dialog opens.
  • docs/ prerendered static output, hydrated in Chromium: 0 hydration issues and zero warnings on /, /guide/, /fr/, /reference/; SPA navigation (/guide/quickstart); theme Light→Dark switches data-theme + cookie and persists across reload; locale English→Français → /fr/guide with lang="fr-FR"; mobile nav opens.
  • vitest 128/128, tsc clean with the patched runtime.

How to consume the fix (maintainer call):

  1. Wait for the next @solidjs/web rc and bump — cleanest.
  2. Ship the temporary pnpm patch of @solidjs/web@2.0.0-rc.13 (whole-dist from next, ~106 KB) on this branch with a TODO to drop it on rc.14 — makes the branch usable today. Note next also carries #3753 (fix!: lowercase on* are attributes) and #3760; SolidBase uses camelCase handlers and is unaffected.

Remaining dev-only diagnostics seen in vite dev (not in the production build), worth fixing as follow-ups: STRICT_READ_UNTRACKED in <Article> and <DirectiveContainer> (reactive children read outside a tracking scope), and an EFFECT_WRITES_OWN_SOURCE loop (an effect writing a signal it reads).

…ttled

The port replaced `startTransition(() => navigate()).then(setLang)` with
`Promise.resolve(navigate())` - but Router 2's navigate() returns void, so the
callback ran on the next microtask, before the navigation committed.

setLocale is now a generator action: the navigation runs as a transition and
onSettled() queues the lang write behind its effects, i.e. after the new route
is in the DOM (verified in Chromium: h1 changes, then lang; back/forward and
locale switching work with no errors).

Tests call setLocale from the test body - Solid 2 rejects actions invoked inside
an owned scope ([ACTION_CALLED_IN_OWNED_SCOPE]) - and flush() the effect queue.
@devagrawal09

Copy link
Copy Markdown
Author

Decision: wait for the next @solidjs/web release rather than vendor the fix.

  • The local pnpm patch used for verification is not committed; the branch pins rc.13 as-is.
  • Until @solidjs/web ships fix(web): spread applies attributes before children, matching ssrElement (#3741) solidjs/solid#3757, the client in this PR will mis-hydrate on every page with a Kobalte trigger (see the comment above). SSR output, prerendered HTML, build, and tests are unaffected.
  • To unblock when it lands: bump @solidjs/web (and solid-js to the matching rc) in package.json, dev/package.json, docs/package.json, re-run pnpm install, and re-verify in a browser (theme switch, locale switch, mobile nav, SPA navigation on the prerendered docs/dist/client).
  • To test interactively before then, the exact verified build is @solidjs/web from solidjs/solid@e44b2e4 (next): pnpm --filter @solidjs/web build and drop dist/ over the installed rc.13 (or pnpm patch it). Only the 6 bundled runtime files differ (+344/−156).

@devagrawal09

Copy link
Copy Markdown
Author

Rewrote the branch to fix commit authorship (same content, dates preserved). Hashes cited in earlier comments map as follows:

old new
a97d0e1 976fca4
9d4e14c d28e850
e979216 ee5514f
e52d111 559fe47
3afbc4f f0fe7fb
e064d29 ed404c2
2a69167 422f501
92ff5cc 2389fa5
2bf459a f978aa3

…outes

Review findings (independent Codex pass on the PR):

- prerender-worker received Vite base but a half-applied patch never used it:
  requests now go to base + path and crawled links are normalized back to app
  paths. Verified discriminatingly with the router base set to /sub/: fixed
  worker prerenders all 15 dev pages, the previous one skipped all 15.
- SolidStart answered unmatched paths with 404 implicitly; Router 2 start mode
  renders an empty shell with 200. SolidBaseRoot now sets httpStatus(404) on the
  server when no route matched (an app's own [...404] route still wins).
- dev/docs routers pass base: import.meta.env.BASE_URL.
- biome: unused helper warning gone; EOF newline in structure.mdx.

Known, pre-existing: SolidBase's link helpers emit root-relative hrefs and are
not base-aware.
@devagrawal09

Copy link
Copy Markdown
Author

Independent review (Codex) — findings and fixes

Had a Codex agent verify the branch from scratch (install, typecheck, biome, tests, library build/pack, both app builds incl. exit behaviour, dev-server probes, diff review, setLocale action audit). Everything in the PR description reproduced. It found three real issues, now fixed:

  1. Prerender ignored Vite base — the worker received base but a half-applied patch never used it. Now requests base + path and normalizes crawled links back to app paths. Verified discriminatingly: with the router's base set to /sub/, the fixed worker prerenders all pages; the previous worker skips them.
  2. Unmatched routes returned 200 in apps without a [...404] route (SolidStart used to answer 404 implicitly; Router 2 start mode renders an empty shell). SolidBaseRoot now sets httpStatus(404) on the server when nothing matched; an app's own 404 route still wins.
  3. Apps' routers now pass base: import.meta.env.BASE_URL.

Also: biome warning (unused helper) resolved; EOF newline in structure.mdx.

Known, pre-existing, not addressed here: SolidBase's link helpers (applyPathPrefix etc.) emit root-relative href="/…" and are unaware of Vite base — a site deployed under a sub-path still needs that work.

…status

The Milestone 1 port turned the page-data createResource into an async createMemo
and dropped deferStream as a no-op. It was not: in streaming render mode the shell
(and the response head) is committed as soon as SolidBaseRoot's <Loading> boundary
suspends on page data, so a [...404] page's httpStatus(404) ran after commit and
was ignored. Only the async render path (prerender, crawler UAs) got 404.

The server createMemo honors deferStream at runtime (dynamic() relies on it) though
it is not on the public MemoOptions type yet; pass it through a typed cast and keep
the 1.x default of true. Verified: docs /nope and /fr/nope -> 404 under streaming,
dev unmatched -> 404, 200s unchanged, prerender unchanged (41 pages + 404.html),
tests 128/128.
@devagrawal09

Copy link
Copy Markdown
Author

Follow-up from the Codex re-check: docs/ answered unknown paths with 200 under the dev server's streaming mode even though the [...404] page rendered. Root cause was a Milestone-1 regression, not the new Root check: the page-data createResource had deferStream: true in 1.x and I had dropped it as a no-op when porting to an async createMemo. Without it the shell (and status) is committed as soon as the <Loading> boundary suspends, so the page's httpStatus(404) arrived too late. Only the async render path (prerender, crawler UAs) was correct — which is why the SSG output always had a proper 404 and I had seen 404 once by timing.

Fixed by restoring deferStream on the page-data memo (props.deferStream ?? true; the server memo honors it at runtime, it just isn't on the public MemoOptions type yet). Verified: docs /nope and /fr/nope → 404 under streaming, dev unmatched → 404, prerender unchanged (41 pages + 404.html), tests 128/128.

…eats the

exact base path as the app root

Found by an independent review (Codex GPT-6-Astra) with a scratch server fixture:
- relative hrefs were resolved against the origin, so <a href="next"> on
  /guide/first was crawled as /next instead of /guide/next;
- a link to exactly the Vite base (no trailing slash) was not stripped and was
  requested as /base/base.

extractLinks now resolves against the page URL (base + path) and toAppPath maps
base and base/ to /. Verified with the fixture under base /review-base/ and /,
the dev build under base /sub/ (15 pages, 0 skipped), and the docs build, whose
skipped-link count dropped from 7 to 6 because one relative link now resolves
correctly. Biome is diagnostic-free on src/.
@devagrawal09

Copy link
Copy Markdown
Author

Second independent review, this time on Codex GPT-6-Astra (the model originally requested; it is in the Codex catalogue even though the subagent driver's advertised list omits it). It reproduced the full PASS set at e3ae444 and found two real crawler bugs via a scratch server fixture, now fixed in the latest commit:

  1. Relative links resolved against the origin — <a href="next"> on /guide/first was crawled as /next. Now resolved against the page URL. Side effect: the docs build's skipped-link count went from 7 to 6 (one of those was exactly this).
  2. Exact-base link (/base without trailing slash) was requested as /base/base. toAppPath now maps base/base/ to /.

Also from that review, informational: Kobalte Select may invoke onChange from an options-change effect, so setLocale (a Solid action) can run from an effect rather than a click; Astra tested action-from-effect in isolation and it does not trip ACTION_CALLED_IN_OWNED_SCOPE, and the real locale switch was verified in a browser earlier. Root-relative hrefs under a non-root base remain the known pre-existing limitation.

@pkg-pr-new

pkg-pr-new Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/kobaltedev/solidbase/@kobalte/solidbase@174

commit: 184f4a0

@jer3m01

jer3m01 commented Oct 6, 2026

Copy link
Copy Markdown
Member

Thanks for the PR!

Still need to review the rewritten documentation, but from the details and comments here everything seems fine.

So we are waiting for a Solid rc release?
I also have lots of fixes for Kobalte I haven't pushed yet, will release soon.

@jer3m01 jer3m01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Few things to change but looks good.
Still waiting on solid and kobalte release.

Comment thread dev/src/routes/ec-file.mdx Outdated
Comment thread src/client/Root.tsx Outdated
Comment thread src/config/vite-plugin/prerender.ts
@devagrawal09

devagrawal09 commented Oct 8, 2026 •

Copy link
Copy Markdown
Author

Updated in cff3c8d. Solid and @solidjs/web are pinned to 2.0.0-rc.14, with Router 2.0.0-next.37. The official web release resolves the hydration mismatch; no local runtime patch is included. All three review requests are addressed.

Verification: frozen install, library build/typecheck, Biome, 29 test files / 128 tests, dev prerender (15 pages + 404), docs prerender (41 pages + 404), and root/non-root base crawler fixtures pass. Static-browser checks pass hydration, SPA forward navigation, theme selection and cookie persistence, mobile menu, and locale switching. Browser scripts and fixtures remain outside the repo.

Still keeping this draft: an additional Back/Forward check changes the URL but can leave the previous route rendered (also reproduced independently). This is not just a stale html lang attribute. The cause still needs isolation; I have not added a runtime workaround or marked the interactive migration fully verified. The six existing broken docs links remain unchanged.

@devagrawal09

Copy link
Copy Markdown
Author

Investigation paused at user request. Detailed continuation handoff: https://github.com/devagrawal09/solidbase/blob/solid2/SOLID2-HANDOFF.md

Correction to the previous blocker update: Claude Opus found a concrete SolidBase/Kobalte interaction in jsdom. Recreated locale options trigger an unchanged-selection callback; during Back this calls setLocale again, pushes a duplicate history entry, and destroys Forward. A selector guard plus route-driven lang effect fixed the jsdom flows. The exact stale-content production-browser symptom is not yet verified against that candidate fix, because native sandbox browser access was blocked and the main harness lacked the matching Chromium revision. No candidate fix or runtime patch has been committed. The handoff records fixtures, candidate code, verification evidence, and the exact next steps.

@devagrawal09

Copy link
Copy Markdown
Author

Status update (184f4a0, still draft):

  • Tab hydration fixed. Direct loads of pages with tab groups threw a hydration mismatch because @solid-primitives/event-listener / resize-observer (pinned by Kobalte 2.0.0-alpha.2) skip creating an owner on the server. The event-listener fix is merged but unreleased; resize-observer is unfixed. Tab groups now use native markup with the same styling, ARIA tabs pattern, keyboard navigation, cookie persistence and cross-tab sync. Happy to switch back to Kobalte once fixed releases land.
  • Prerender/sitemap/llms respect fileRoutes.dir; a missing routes dir or fileRoutes: false no longer crashes the build.
  • Smaller fixes: missing-provider error messages restored, font preloads restored, deploy docs no longer reference Nitro, saved JS language preference no longer overwritten on load, prerender worker flushes stdout before exit.

Verified: 138 tests, build/typecheck/Biome, dev 15 + docs 41 prerendered pages, browser checks on direct loads, tabs, history, locale, theme and mobile nav, including this deploy preview.

Open questions for maintainers: merge timing, versioning/dist-tag, and whether router/meta should be peer deps.

@jer3m01

jer3m01 commented Oct 15, 2026

Copy link
Copy Markdown
Member

Kobalte can be bumped to @kobalte/core@2.0.0-alpha.3, should be able to remove the custom implementations and go back to kb. Tabs storage should also work with the new @solid-primitives/storage@5.0.0-next.7 release.

Kobalte 2.0.0-alpha.4 requires solid-js/@solidjs/web exactly 2.0.0-rc.16 and pins the fixed event-listener/keyboard/scroll primitives; align our direct pins with it so one copy is installed. storage 5.0.0-next.7 adds explicit dispose and the hydrated option.
…sistence

Kobalte 2.0.0-alpha.4 no longer shifts hydration keys on direct loads, so go back to Kobalte Tabs in tab groups and Accordion in the dev preview. Named groups persist via makePersisted (cookieStorage + messageSync over a client-only BroadcastChannel) with hydrated: true, disposing the subscription and channel on cleanup.
…solidjs/solid#4013)

solid-js rc.15+ no longer leaves lazy() preloads started under the hydration Promise stub unsettled, so remove the await undefined yield and its stub-based test. Page data keeps registering under Vite-root-relative module keys.
@devagrawal09

Copy link
Copy Markdown
Author

Status update (811e07d, still draft), following up on @jer3m01's comment:

  • Back to Kobalte. Bumped to @kobalte/core@2.0.0-alpha.4 (alpha.3 still pinned the primitives that shifted hydration keys; alpha.4 includes fix: update Solid 2 primitives for hydration symmetry kobalte#746) and Solid/web 2.0.0-rc.16, which alpha.4 requires. Tab groups use Kobalte Tabs again and the dev preview uses Kobalte Accordion; the custom implementations are removed.
  • Tab storage via @solid-primitives/storage@5.0.0-next.7. Named groups use makePersisted with cookie storage, messageSync over a client-only BroadcastChannel, and hydrated: true; dispose and the channel are cleaned up together. If the saved tab isn't in a group (keys are shared across pages), that group shows its first tab without overwriting the cookie.
  • lazy() workaround removed. The await undefined before preload() is gone; Hydration tracing permanently caches a mocked promise in lazy().preload() solidjs/solid#4013 is fixed in rc.15+. The page-data cache key fix stays.

Verified: 137 tests, build, typechecks, Biome, pack; dev 15 pages + 404, docs 41 pages + 404. Production-build browser checks: direct loads of tab pages with no hydration warnings, keyboard navigation, same-page and cross-tab sync, cookie restore on reload, Back/Forward to the lazily loaded route, locale, theme, copy, and mobile nav.

Notes: arrow-key direction now follows Kobalte's locale rather than the page's dir="rtl" (as with Kobalte tabs before). The saved tab is applied about 45 ms after hydration, so the server still renders the first tab. SOLID2-HANDOFF.md will be removed before this is ready for review.

— Claude (Opus 5.5), posting on behalf of @devagrawal09

… of duplicating

Keyless inline <Script>s get a per-runtime identity from @solidjs/web useHead
("u:N" on the server, "u:cN:i" on the client), so the client never matches the
prerendered tag and appends a second copy, which also re-executes. Give the theme
and preferred-language scripts a stable key so both runtimes share the identity
"script:key:<id>" and the server-rendered element is claimed.
@devagrawal09
devagrawal09 requested a review from jer3m01 October 21, 2026 01:28
…ange

Kobalte's Select re-emits onChange for the kept selection whenever its
options change, and `locales` is rebuilt on every navigation, so the
LocaleSelector called setLocale(currentLocale) after each route change.
After Back on a trailing-slash URL (Netlify pretty URLs: /x -> /x/), that
navigate('/x') differed from the current '/x/' and pushed a new entry,
dropping the Forward history.

Only navigate when the selected locale actually differs, and derive
<html lang> from the current location via an effect instead of relying on
those spurious setLocale calls (which kept lang in sync on links and
Back/Forward). setLocale is a plain function again, matching main and the
documented `setLocale(locale): void`.
Solid 2's compiler (@solidjs/compiler) no longer aliases className to
class, but MDX defaults to React attribute casing (className, htmlFor).
Every class from markdown (Expressive Code frames/copy buttons/line marks,
the TS/JS language groups, GFM task lists, footnotes) rendered as a
literal className attribute. Set elementAttributeNameCase: 'html'.
…e on static hosts

A single 404.html rendered for /__solidbase_404__ was served for every missing
URL, but the client hydrates against the real URL: /fr/* matches fr/[...404]
(French, lang=fr-FR) and /guide/* renders the guide sidebar, so hydration
failed with 'Unable to find DOM nodes'. Render the probe inside each directory
and keep <dir>/404.html where it differs from its parent's; route Netlify's
fallback to them with 404-status rewrites.
…form

SolidBase always compiles MDX with jsx: true so Solid's compiler handles the
JSX; the esbuild es2019/classic-pragma pass (inherited from the React
vite-plugin-mdx) only ran for jsx: false, where MDX already emits plain
jsx() calls. Removing it means the published package no longer needs
esbuild (Vite 8 doesn't ship it, and it needs an approved postinstall
under pnpm 11).
resolve, vfile and @types/mdast are imported by published dist code/types
but were only devDependencies, so a clean consumer failed to build.
The published ./config/route export pointed at nonexistent
src/config/route-config.js/.d.ts.
Both apps import @solidjs/meta directly but relied on the workspace root
node_modules to resolve it.
… the build

The prerender worker imports and runs the app's server bundle, so any console.log
in consumer code (e.g. Document.tsx) landed on the stdout the parent parsed as JSON,
failing vite build after rendering succeeded. Pass input and result over an IPC
channel and let the worker's stdout/stderr through to the user.
Directory discovery dropped each page's final segment, so a directory with only an
index page (fr/index.mdx -> /fr) was never probed and got no fr/404.html; hosts then
served the root 404 under /fr/* and hydration mismatched. Probe every page path too;
probes identical to their parent's 404 are still skipped.
…ter base

With a Vite base (eg. --base /docs/), pages rendered with lang="en", English
navigation and no section sidebar, and internal links left the base. Solid
Router 2's location includes the base, while SolidBase routes, locales and
sidebars are base-relative; the port also replaced <A> (which resolved hrefs
against the base) with plain anchors.

- Strip the router base (useResolvedPath("/")) before locale, route and
  prev/next matching; getLocale() strips Vite's base for the document shell.
- Resolve app-absolute hrefs on plain anchors (sidebar, header nav and logo,
  prev/next, hero actions, markdown links, Link, version/project selectors)
  with the router's useResolvedPath.
- Pass the router base without Vite's trailing slash: resolvePath keeps it,
  producing /docs//guide hrefs that also broke active link state.
Same-origin links that resolve outside the base were remapped into the app
and crawled, so a build whose links escape the deployment still reported
every page as successful. Skip them and warn once per link instead.
…idbase/types

The guide only installed SolidBase. An existing app also needs the Solid 2
packages at the pinned versions, the SolidStart files removed (start mode
picks up src/entry-*.tsx), jsxImportSource @solidjs/web, and types for
virtual:file-routes / virtual:solidbase/* / ~icons/*. The workspace got
those from ../src/**/*.d.ts includes that consumers don't have.

Add a ./types export (src/types.d.ts, copied to dist on build) bundling
filesystem-routing/types with SolidBase's virtual and icon declarations,
document it, and dogfood it in docs/ and dev/.
- CurrentPageDataProvider: deferStream is a creation-time memo option; untrack it.
- DirectiveContainer: keep the children() memo and read it in JSX instead of
  snapshotting toArray() in the component body.
- useThemeText: return a merge() view so route/locale text stays reactive.
- DocSearch: read the options in the effect's compute and destroy the
  instance on re-run/cleanup instead of reading props in the callback.
Solid Router 2 claims every same-origin anchor and sets aria-current="page" when its path and query match the location, ignoring the hash. The skip link, table of contents and heading/markdown #anchors therefore all announced themselves as the current page (75 on /guide/features/markdown). Links with a fragment now render an explicit aria-current="false", which the router leaves untouched, so only the sidebar link for the page is current, as on main.
Static hosts (eg. Netlify pretty URLs) serve pages at /guide/, while sidebar
links, routes and locales are written without a trailing slash. usePrevNext
compared the location with the flattened sidebar links exactly, so on /guide/
the current page wasn't found (index -1) and "Next" pointed at the first
sidebar item, often the current page itself.

- stripBasePath (behind useAppPathname and getLocale) now also trims trailing
  slashes, so prev/next, route and locale matching see one canonical path.
- Legacy locale detection matches /fr as well as /fr/ against a /fr/ link.
@devagrawal09

Copy link
Copy Markdown
Author

Status update (9530003), after a deep review pass of the whole PR:

  • Package is installable outside the workspace. Runtime imports (resolve, vfile, @types/mdast) are now dependencies, ./config/route points at dist, and the unused esbuild MDX downlevel step is removed. Verified by building a fresh app from the packed tarball (these manifest gaps also exist on main).
  • Prerender: results come back over IPC, so app logs no longer break vite build; index-only locale/section directories get their own 404.html.
  • Non-root base: links resolve against the router base and locale/sidebar matching strips it (restores what <A> did on main); links escaping the base are reported. Verified with --base /docs/.
  • Static-host fixes from preview testing: MDX now emits class (Solid 2's compiler no longer rewrites className, so Expressive Code/task lists had lost their classes), Forward after Back works with Netlify's trailing slashes, missing URLs hydrate with the right locale/sidebar, prev/next is trailing-slash safe, in-page links no longer get aria-current="page", head cookie scripts run once.
  • Docs: the Adding to Existing Project guide now covers the Solid 2 packages, jsxImportSource, and a new @kobalte/solidbase/types entry for virtual modules.

139 tests, typechecks, Biome, builds (docs 41 pages, dev 15) and a full browser pass on the deploy preview are green. The PR description is updated, including known limitations and follow-ups filed upstream (kobaltedev/kobalte#753, kobaltedev/kobalte#754, #176).

— Claude (Opus 5.5), posting on behalf of @devagrawal09

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.

default-theme: module-scope mobileLayout watcher leaks a computation and breaks hydration on viewports ≤ 1100px

2 participants