Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dev-toolbar-portal-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

Fix the dev overlay corrupting the first client-side navigation after an SSR page load. The toolbar's `<Portal>` handed the hydrating tree a client-created marker node that hydration never inserts into the DOM, and that phantom bookkeeping entry made the first route swap orphan the previous page's DOM (both pages visible at once). The toolbar now renders in its own root outside the app tree, mounted only after the SSR stream completes, since rendering during streaming steals hydration keys from pending suspense chunks.
5 changes: 5 additions & 0 deletions .changeset/dev-toolbar-prebundle-trace-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

Pre-bundle `@jridgewell/trace-mapping` for the client in dev so the dev overlay's lazily-loaded error viewer stops rejecting with `The requested module ... does not provide an export named 'default'`. The viewer's import chain is only reachable through `@solidjs/start` itself, so Vite's dep scanner never discovers it and served its CJS/UMD dependencies raw.
10 changes: 10 additions & 0 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
environments: {
[VITE_ENVIRONMENTS.client]: {
consumer: "client",
optimizeDeps: {
// The dev toolbar's lazily-imported error viewer reaches
// @jridgewell/trace-mapping (and its CJS/UMD deps) only through
// @solidjs/start itself, so the dep scanner never discovers it;
// unprebundled, the browser gets the raw UMD files and the
// import rejects ("does not provide an export named ...").
// The "a > b" form resolves through @solidjs/start under
// strict (pnpm) node_modules layouts.
include: start.devOverlay ? ["@solidjs/start > @jridgewell/trace-mapping"] : [],
},
build: {
write: true,
manifest: true,
Expand Down
98 changes: 67 additions & 31 deletions packages/start/src/shared/dev-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import {
createSignal,
ErrorBoundary,
onCleanup,
onMount,
resetErrorBoundaries,
Show,
type JSX,
} from "solid-js";
import { createStore } from "solid-js/store";
import { Portal } from "solid-js/web";
import { isServer, render } from "solid-js/web";
import { Toolbar } from "terracotta";
import info from "../../../package.json" with { type: "json" };
import clientOnly from "../clientOnly.ts";
Expand Down Expand Up @@ -208,38 +209,73 @@ export function DevToolbar(props: DevToolbarProps) {
);
});

const ToolbarUI = () => (
<div data-start-dev-toolbar ref={setRef}>
<Toolbar>
<div>
<IconButton onClick={() => toggleContent("err")} disabled={errors().length === 0}>
<ErrorIcon title="View Errors" />
</IconButton>
<IconButton onClick={() => toggleContent("fn")}>
<FunctionIcon title="View Server Functions" />
</IconButton>
</div>
<div>
<SolidStartIcon title="Solid Start Version" />
<div data-start-dev-toolbar-version>
<Text options={{ size: "xs", weight: "semibold", font: "mono", wrap: "nowrap" }}>
{info.version as string}
</Text>
</div>
</div>
</Toolbar>
<ErrorViewer show={content() === "err"} errors={errors()} resetError={resetError} />
<ServerFunctionViewer
show={content() === "fn"}
instances={store.instances}
onDeleteInstance={value => {
setStore("instances", value, undefined);
}}
/>
</div>
);

// The toolbar must stay out of the hydrated app tree. A <Portal> here hands
// the hydrating parent a client-created marker node that hydration never
// inserts into the DOM, and that phantom entry in insert()'s bookkeeping
// corrupts the first reconcile after a navigation (the previous page's DOM
// is left behind). So it gets its own render root — but that root must not
// be created while the SSR stream is still open either, or hydration of the
// pending suspense chunks breaks. The document stays in "loading" readyState
// for the whole stream, so DOMContentLoaded is the stream-end signal.
if (!isServer) {
onMount(() => {
let dispose: (() => void) | undefined;
let container: HTMLElement | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleMount = () => {
timer = setTimeout(() => {
container = document.createElement("div");
document.body.appendChild(container);
dispose = render(ToolbarUI, container);
}, 0);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scheduleMount, { once: true });
} else {
scheduleMount();
}
onCleanup(() => {
document.removeEventListener("DOMContentLoaded", scheduleMount);
clearTimeout(timer);
dispose?.();
container?.remove();
});
});
}

return (
<>
<Portal>
<div data-start-dev-toolbar ref={setRef}>
<Toolbar>
<div>
<IconButton onClick={() => toggleContent("err")} disabled={errors().length === 0}>
<ErrorIcon title="View Errors" />
</IconButton>
<IconButton onClick={() => toggleContent("fn")}>
<FunctionIcon title="View Server Functions" />
</IconButton>
</div>
<div>
<SolidStartIcon title="Solid Start Version" />
<div data-start-dev-toolbar-version>
<Text options={{ size: "xs", weight: "semibold", font: "mono", wrap: "nowrap" }}>
{info.version as string}
</Text>
</div>
</div>
</Toolbar>
<ErrorViewer show={content() === "err"} errors={errors()} resetError={resetError} />
<ServerFunctionViewer
show={content() === "fn"}
instances={store.instances}
onDeleteInstance={value => {
setStore("instances", value, undefined);
}}
/>
</div>
</Portal>
<ErrorBoundary
fallback={error => {
pushError(error);
Expand Down
Loading