Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
033484e
fix(db): ask before reset or seed drops tables on a non-local database
Brooooooklyn Oct 11, 2026
e04ca21
fix(db): drop MySQL tables on reset by aliasing the table name column
Brooooooklyn Oct 11, 2026
593a89a
fix(db): load seed and schema modules that use extensionless imports
Brooooooklyn Oct 11, 2026
41371cc
fix(init): keep existing pnpm build approvals in pnpm-workspace.yaml
Brooooooklyn Oct 11, 2026
492f78d
fix(cli): spawn node without a shell on Windows
Brooooooklyn Oct 11, 2026
a430105
fix(cli): upload Worker limits, placement and cache with pinned secrets
Brooooooklyn Oct 11, 2026
5e8d17c
fix(deploy): send custom binding names in the managed deploy manifest
Brooooooklyn Oct 11, 2026
8630a81
fix(astro): stop ignoring src/middleware.ts after the dev server stops
Brooooooklyn Oct 11, 2026
7d7e1dc
fix(void): support D1 sessions on the remote dev binding
Brooooooklyn Oct 11, 2026
68476fd
fix(db): split drizzle statement breakpoints in MySQL migrations
Brooooooklyn Oct 11, 2026
c14338e
fix(auth): keep MySQL auth migrations idempotent on MySQL 8
Brooooooklyn Oct 11, 2026
6ac0e4d
fix(react): reject the page render when a React page throws during SSR
Brooooooklyn Oct 11, 2026
4ab32ee
fix(solid): keep layouts and page state across navigation and updates
Brooooooklyn Oct 11, 2026
bd55219
fix(svelte): bundle compiled CSS for island pages and their layouts
Brooooooklyn Oct 11, 2026
e17f70f
fix(cli): read Wrangler configs that start with a UTF-8 BOM
Brooooooklyn Oct 11, 2026
c71da13
fix(db): export every MySQL table and restore with foreign keys off
Brooooooklyn Oct 11, 2026
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
2 changes: 2 additions & 0 deletions docs/reference/api/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ Imported from `"@void/solid"`.

Solid `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links.

Solid re-creates the page on GET navigation and whenever the route or its params change, so `useForm()` picks up the destination record's URL and defaults; layouts persist. Within the same record, back/forward, mutations, and `router.refresh()` keep the page and update its props in place, as do resolved deferred props, so read props inside JSX or memos instead of copying them in the component body. Set `preserveState` explicitly to override the default for navigation within the same record.

## Markdown Package {#markdown-package}

The optional `@void/md` package adds Markdown pages to Pages Routing. Install it alongside a Pages adapter and add `voidMarkdown()` to your Vite plugins.
Expand Down
12 changes: 9 additions & 3 deletions docs/reference/cli/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,21 @@ Show migration status. Displays which migrations are applied or pending locally,

## `void db reset` {#void-db-reset}

Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`. Does not affect the deployed database.
```
void db reset [--yes]
```

Drop the development database and re-apply all migrations. D1 resets the local database; PostgreSQL and MySQL drop all tables in the `DATABASE_URL` database from `.env`.

When that `DATABASE_URL` points to a host other than `localhost`, Void names the database and asks before dropping its tables. Pass `--yes` to skip the prompt in scripts and CI; without a terminal, the command stops unless `--yes` is set. If `.env` uses the same URL as your deployment (for example after `void db connect`), reset drops the deployed tables.

## `void db seed` {#void-db-seed}

```
void db seed [--file <path>]
void db seed [--file <path>] [--yes]
```

Reset the local database, re-apply all migrations, then execute a seed file.
Reset the local database, re-apply all migrations, then execute a seed file. The reset works like `void db reset`, including the prompt and `--yes`.

If `--file` is omitted, Void looks for default seed files in this order: `db/seed.ts`, `db/seed.mts`, `db/seed.js`, `db/seed.mjs`, `db/seed.sql`.

Expand Down
16 changes: 15 additions & 1 deletion packages/react/src/runtime/pages-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,28 @@ export async function renderReactPage({
)
: element;

// React calls onError inside its work loop, so it must not throw. The outer
// Suspense turns page errors into a client-render fallback; fail the render
// instead when they happen before the shell, so the caller serves its error page.
let shellError: { error: unknown } | undefined;
let shellSent = false;
const appStream = await renderToReadableStream(renderElement, {
onError(error) {
if (error === DEFERRED_SHELL_CANCEL_REASON) {
return;
}
throw error;
if (shellSent) {
console.error(error);
return;
}
shellError ??= { error };
},
});
if (shellError) {
await appStream.cancel(shellError.error);
throw shellError.error;
}
shellSent = true;
const renderedAppStream = pageObj.deferredKeys ? readDeferredShell(appStream) : appStream;
const pageData = serializePageData(pageObj);
const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : '';
Expand Down
18 changes: 18 additions & 0 deletions packages/react/test/unit/pages-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, it } from 'vite-plus/test';
import { renderReactPage } from '../../src/runtime/pages-server.ts';

it('rejects with the error a page throws while rendering', async () => {
const boom = new TypeError('page render failed');
function Page(): never {
throw boom;
}

await expect(
renderReactPage({
pageObj: { component: 'page', props: {}, url: '/', params: {}, shared: {}, errors: null },
assetTags: { css: '', preloads: '', body: '' },
components: { page: async () => ({ default: Page }) },
layoutTree: {},
}),
).rejects.toBe(boom);
});
54 changes: 38 additions & 16 deletions packages/solid/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ function generateClientEntry(
staticPageData?: StaticPageDataBuildOptions,
): string {
return `
import { createSignal, createComponent, batch } from "solid-js";
import { createSignal, createComponent, createMemo, mergeProps, untrack, batch } from "solid-js";
import { createMutable } from "solid-js/store";
import { hydrate, render } from "solid-js/web";
import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext, setActionRouter } from "@void/solid";
Expand All @@ -265,7 +265,8 @@ import { parseCacheFor } from "void/pages-prefetch";

${generateComponentManifest(scan, pagesDir)}

const [component, setComponent] = createSignal(null);
// A new page object re-creates the page; props alone update it in place.
const [page, setPage] = createSignal({ component: null });
const [pageProps, setPageProps] = createSignal({});
const [layouts, setLayouts] = createSignal([]);
const shared = createMutable({});
Expand All @@ -280,21 +281,31 @@ function replaceShared(next) {
Object.assign(shared, next || {});
}

function sameParams(a, b) {
const keys = Object.keys(a);
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}

const { router, initialPageData, initialComponent, initialLayouts, start, commitInitialRender } =
await createVoidRouter({
adapter: {
createDeferred: () => ({ value: null, loading: true, error: null }),
resolveDeferred(ref, value) { return { value, loading: false, error: null }; },
rejectDeferred(ref, error) { return { value: null, loading: false, error: new Error(error) }; },
isLoading: (ref) => ref.loading,
applyUpdate(pageData, comp, layoutComps) {
applyUpdate(pageData, comp, layoutComps, options) {
const params = pageData.params || {};
// Back/forward preserves state, but another route or another record
// still needs a new page so useForm() targets that route and record.
const current = page();
const keep = options?.preserveState && current.id === pageData.component && current.component === comp && sameParams(routeParams(), params);
batch(() => {
if (!keep) setPage({ component: comp, id: pageData.component });
setPageProps(pageData.props);
setComponent(() => comp);
setLayouts(layoutComps);
replaceShared(pageData.shared);
setErrors(pageData.errors || {});
setRouteParams(pageData.params || {});
setRouteParams(params);
});
},
onDeferredUpdate(props) {
Expand All @@ -320,14 +331,34 @@ const reactiveRouter = createRouterFacade(router, () => routeUrl(), () => routeP
setActionRouter(reactiveRouter);

batch(() => {
setComponent(() => initialComponent);
setPage({ component: initialComponent, id: initialPageData.component });
setPageProps(initialPageData.props);
setLayouts(initialLayouts);
replaceShared(initialPageData.shared);
setErrors(initialPageData.errors || {});
setRouteParams(initialPageData.params || {});
});

// Each slot re-renders only when its own layout or page changes, so
// layouts above it keep their state. The inner slot is created before its
// layout to match the server render order for hydration.
function renderSlot(depth) {
const entry = createMemo(() => {
const list = layouts();
return depth < list.length ? list[depth] : page();
});
return createMemo(() => {
const current = entry();
return untrack(() => {
if (typeof current === "function") {
const child = renderSlot(depth + 1);
return createComponent(current, { get children() { return child; } });
}
return current.component ? createComponent(current.component, mergeProps(pageProps)) : null;
Comment thread
Brooooooklyn marked this conversation as resolved.
});
});
}

function App() {
return createComponent(SharedContext.Provider, {
value: shared,
Expand All @@ -344,16 +375,7 @@ function App() {
return createComponent(NavigationContext.Provider, {
value: navigation,
get children() {
const Comp = component();
if (!Comp) return null;
let element = createComponent(Comp, pageProps());
const layoutList = layouts();
for (let i = layoutList.length - 1; i >= 0; i--) {
const Layout = layoutList[i];
const child = element;
element = createComponent(Layout, { get children() { return child; } });
}
return element;
return renderSlot(0);
}
});
}
Expand Down
11 changes: 6 additions & 5 deletions packages/solid/src/runtime/use-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,12 @@ export function useForm(
const [wasSuccessful, setWasSuccessful] = createSignal(false);
const [recentlySuccessful, setRecentlySuccessful] = createSignal(false);

let defaults_ = { ...defaults };
const [defaults_, setDefaults] = createSignal<Record<string, unknown>>({ ...defaults });
let successTimeout: ReturnType<typeof setTimeout> | null = null;

const hasChanges = createMemo(() => {
return Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key]));
const current = defaults_();
return Object.keys(current).some((key) => !isEqualFormValue(data[key], current[key]));
});

function setData(field: string, value: unknown) {
Expand All @@ -105,10 +106,10 @@ export function useForm(

function reset(...fields: ReadonlyArray<string>) {
if (fields.length === 0) {
setDataStore(reconcile({ ...defaults_ }));
setDataStore(reconcile({ ...defaults_() }));
} else {
for (const field of fields) {
setDataStore(field, defaults_[field]);
setDataStore(field, defaults_()[field]);
}
}
}
Expand Down Expand Up @@ -166,7 +167,7 @@ export function useForm(

setWasSuccessful(true);
setRecentlySuccessful(true);
defaults_ = { ...data };
setDefaults({ ...data });
successTimeout = setTimeout(() => {
setRecentlySuccessful(false);
}, 2000);
Expand Down
77 changes: 77 additions & 0 deletions packages/solid/test/integration/solid-pages-playground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ declare global {
}
}

type StampedElement = HTMLElement & { __stamp?: number };

const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
let DEV_BASE: string;
let PREVIEW_BASE: string;
Expand Down Expand Up @@ -87,6 +89,81 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full')
}
});

it('layout and page state persist across navigation and validation errors', async () => {
const page = await newPage();
const stampOf = (selector: string) =>
page.evaluate((s) => document.querySelector<StampedElement>(s)?.__stamp, selector);
try {
await page.goto(BASE + '/');
await waitForHydration(page);
await page.evaluate(() => {
document.querySelector<StampedElement>('nav')!.__stamp = 1;
});

await page.click('nav a[href="/users"]');
await page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('Users'),
);
expect(await stampOf('nav')).toBe(1);

await page.evaluate(() => {
document.querySelector<StampedElement>('h1')!.__stamp = 1;
});
await page.locator('form input').nth(0).fill('Typed Name');
await page.click('button[type="submit"]');
await page.waitForFunction(() => document.body?.textContent?.includes('Email is required'));

expect(await page.locator('form input').nth(0).inputValue()).toBe('Typed Name');
expect(await stampOf('h1')).toBe(1);
} finally {
await page.context().close();
}
});

it('back/forward to another record of the same page re-creates the page', async () => {
const page = await newPage();
try {
await page.goto(BASE + '/users/1');
await waitForHydration(page);
await page.evaluate(() => {
document.querySelector<StampedElement>('h1')!.__stamp = 1;
history.pushState(null, '', '/users/2');
history.pushState(null, '', '/users');
history.back();
});
await page.waitForFunction(() => document.querySelector('h1')?.textContent?.includes('Bob'));

expect(
await page.evaluate(() => document.querySelector<StampedElement>('h1')?.__stamp),
).toBeUndefined();
} finally {
await page.context().close();
}
});

it('back/forward to another route that shares the page component re-creates the page', async () => {
const page = await newPage();
try {
await page.goto(BASE + '/twins/a');
await waitForHydration(page);
await page.evaluate(() => {
document.querySelector<StampedElement>('h1')!.__stamp = 1;
history.pushState(null, '', '/twins/b');
history.pushState(null, '', '/');
history.back();
});
await page.waitForFunction(
() => document.querySelector('[data-testid="twin-path"]')?.textContent === '/twins/b',
);

expect(
await page.evaluate(() => document.querySelector<StampedElement>('h1')?.__stamp),
).toBeUndefined();
} finally {
await page.context().close();
}
});

it('Link preserves Solid bound onClick handler arguments', async () => {
const page = await newPage();
try {
Expand Down
Loading
Loading