Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ packages/*/index.d.ts
!packages/solid-layouts-oxc/index.d.ts
!packages/rsbuild-plugin-solid-layouts/index.js
!packages/rsbuild-plugin-solid-layouts/index.d.ts
packages/solid-layouts/solid-2-staging/
169 changes: 169 additions & 0 deletions SOLID-2-PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Solid 2 support for the layouts toolchain

Scoped 2026-08-15 on `next/solid-2`, branched from `master` at `a107bf1`, which
is exactly the published state: solid-layouts 0.1.3, solid-layouts-oxc 0.1.7,
rsbuild-plugin-solid-layouts 0.1.4. Tagged `solid-1-baseline`.

**Status: built, tested, unpublished.** The toolchain now serves both majors.
Steps 1 to 5 below are done; step 6, publishing, is not.

## It is one file, and inside that file one import

I had this down as "three packages need Solid 2 support", which was true but
useless. Measured, the whole dependence was:

| where | Solid sites | note |
| --- | ---: | --- |
| `packages/solid-layouts/src/component.ts` | **32** | 437 lines. This was the job. |
| `packages/solid-layouts/src/component.test.ts` | 34 | follows the source |
| `packages/solid-layouts/src/ids.test.ts` | 3 | `createRoot` only |
| `packages/solid-layouts-oxc` (3,850 lines of Rust) | **2 real** | an emitted import header and a builtins list |
| `packages/rsbuild-plugin-solid-layouts` | **0** | a thin wrapper, no Solid usage |

Then it got smaller again. Of the four things `component.ts` used that 2.0
changed, three can be told apart at runtime from the module object itself, so
they needed no fork at all:

| what | how it is handled |
| --- | --- |
| `splitProps` became `omit` | resolved once at load: `"omit" in solid` |
| `Context.Provider` became the context | `context.Provider ?? context` |
| `useContext` throws instead of returning `undefined` | the internal defaults context now carries `{}`, which both majors read the same way |
| **`Dynamic` and `createComponent` moved module** | **the only real fork** |

The fourth cannot be detected, because it is a module specifier and those are
resolved before any code runs. 1.9 serves them from `solid-js/web`; 2.0 moved
them to `@solidjs/web` **and dropped the `solid-js/web` subpath entirely**, so
no single import statement resolves under both. That is now `src/renderer.ts`,
twenty lines, with a twin in `src/renderer.solid-2.ts`. `scripts/build.mjs`
emits the tree twice with the twin swapped in. `dist/component.js` and
`dist/solid-2/component.js` are identical files.

## Three things the migration guide did not tell me

Read out of `solid-js@2.0.0-rc.0` and `@solidjs/signals@2.0.0-rc.0` rather than
out of the guide, and all three changed the plan:

1. **`Dynamic` survived unchanged.** It is still a component taking a
`component` prop, exported from `@solidjs/web`. The plan predicted a rewrite
to a `dynamic(source)` factory. `dynamic()` exists, but `Dynamic` does too,
and `createComponent` is re-exported by `@solidjs/web` from `solid-js`, so
the two names the renderer needs are the same two names in both majors.
2. **`JSX` is gone from `solid-js`.** Core no longer declares the namespace at
all, because the shape of an element is the renderer's business. It comes
from `@solidjs/web` now. The plan missed this entirely, and it is the reason
the renderer module has to carry the type as well as the two values.
3. **`useContext` throws in the implementation, not just the docs.**
`getContext` throws `ContextNotFoundError` when the resolved value is
`undefined` and `NoOwnerError` when there is no owner. A provider supplying
`undefined` counts as having provided, so an empty provider **shadows a real
one above it with a throw**. `defineComponent` now skips the wrapper when the
setup returned no context, which is a correctness fix under 2.0 and a no-op
under 1.9.

## `splitProps` was the part that changed shape

Four buckets came out of one call, and the call made them disjoint by
construction: a key named in two lists landed in the earlier one only.

```js
const [presentation, escape, behaviour, passthrough] =
splitProps(props, presentationKeys, ["class", "className", "style", "children"], behaviourKeys);
```

`omit` returns only the remainder, and neither major ships the subset half. So
the three routed buckets are picked by name, the fourth still falls out of one
call, and which bucket each declared key belongs to is decided **once per
component** rather than once per render.

## The compiler: an option, not a fork

`solid: 1 | 2` on `TransformOptions`. It decides one thing, the specifier a
generated component imports its boundary from:

```
solid-layouts/application-boundary -> solid-layouts/solid-2/application-boundary
```

Told rather than sniffed: the compiler sees one source file, and which Solid an
application runs on is not written in it. Default is 1 and the emission is
byte-identical to before, asserted by a test rather than by inspection.

The builtins list gained 2.0's renames (`Suspense` to `Loading`, `SuspenseList`
to `Reveal`, `ErrorBoundary` to `Errored`, plus `Repeat`) and the source check
admits `@solidjs/web`. Neither is gated behind the option: the check tests a
name against a module, and no 1.9 build can import `Loading` from a Solid module
that does not export one.

## Why the specifier and not an export condition

Both work. A `solid-2` export condition would keep the generated files
byte-identical between majors and cost no compiler change at all. It was built
that way first and then removed, for one reason: a specifier is a line you can
read. `grep` answers which runtime a build is on, and a mismatch reads as a
wrong-looking import rather than as a resolve failure two layers down. The
condition also needed `customConditions` in every consumer's tsconfig to keep
tsc agreeing with the bundler.

The cost of the explicit form is one edit in `@pathscale/ui`, because the
library funnels every hand-written runtime import through `src/lib/layouts/index.ts`.

## The plugins

Two more exported names rather than an option on the existing two:

```js
pluginSolid2LayoutsLibrary(),
pluginBabel({ include: /\.(?:jsx|tsx)$/ }),
pluginSolid2(),
```

The choice is not independent of the rest of the build. It has to agree with
`pluginSolid2()` and with the installed `solid-js`, and a name that must match
its neighbour in the plugin list is easier to get right than a flag that must.
`pluginSolidLayoutsLibrary`/`Application` are untouched.

Asking a runtime that predates this for `./solid-2` now fails by name rather
than quietly falling back to `main` and serving the wrong major.

## Order of work

1. ~~**`component.ts` prop routing.**~~ Done. `pick` plus one `rest`, buckets
resolved per component.
2. **The context decision.** Half done. The runtime's own context carries a
default now, which settles `solid-layouts`. The library question is open:
`@pathscale/ui` calls `useContext` at **57 sites with only 17 guarded**, and
every compound component that currently works standalone throws under 2.0
unless each context gets a default. That is an API decision about what 3.0
*is*, and it does not belong in this repository.
3. ~~**`@solidjs/web` imports.**~~ Done, and smaller than expected: `Dynamic`
did not change shape.
4. ~~**Rust: builtins list, source check, boundary option.**~~ Done. 60 Rust
tests, 22 JS tests.
5. ~~**`rsbuild-plugin-solid-layouts`.**~~ Done. Still true that it sits beside
`@rsbuild/plugin-solid@1.2.2`, which *depends on* `babel-preset-solid:
^1.9.12`. That is a hard pin on Solid 1's JSX transform and needs an override
to `babel-preset-solid@next` or a patched plugin. Untested.
6. **Publish under a prerelease tag** (`0.2.0-rc.0` on `next`) so
`@pathscale/ui` on `next/solid-2` can consume it without touching `latest`.
Not done.

## What is verified, and what is not

Verified by running it: 145 runtime tests under 1.9 unchanged; both builds
emitted; 60 Rust tests; 22 compiler JS tests; the boundary specifier checked
end to end through the rebuilt native binding in both bundle and source mode.

**Not verified:** the 2.0 build has never run against an installed `solid-js@2`.
It typechecks with `skipLibCheck`, so its declarations bind to `@solidjs/web`
but nothing has checked `@solidjs/web` against the Solid 2 it expects. The first
real test is a `@pathscale/ui` build on Solid 2, and that is the next thing to
do.

## What is NOT blocked on this

The 11 `@solid-primitives/*` packages `@pathscale/ui` depends on peer-pin
`solid-js: ^1.6.12` and call `createEffect`/`onCleanup` internally, both of
which change signature. They break at runtime, not at install, and no work here
fixes that. Either upstream ships Solid 2 releases, or the four or five we
actually use get vendored.
2 changes: 2 additions & 0 deletions packages/rsbuild-plugin-solid-layouts/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export {
pluginSolid2LayoutsApplication,
pluginSolidLayoutsApplication,
type ApplicationLayoutSource,
type SolidLayoutsApplicationOptions,
} from "solid-layouts-oxc/application";
export {
pluginSolid2LayoutsLibrary,
pluginSolidLayoutsLibrary,
type SolidLayoutsLibraryOptions,
} from "solid-layouts-oxc/library";
5 changes: 5 additions & 0 deletions packages/rsbuild-plugin-solid-layouts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ import library from "solid-layouts-oxc/library";

export const pluginSolidLayoutsApplication = application.pluginSolidLayoutsApplication;
export const pluginSolidLayoutsLibrary = library.pluginSolidLayoutsLibrary;

// The Solid 2.0 pair. Separate names rather than an option, so the plugin list
// reads as one choice: `pluginSolid2LayoutsLibrary()` beside `pluginSolid2()`.
export const pluginSolid2LayoutsApplication = application.pluginSolid2LayoutsApplication;
export const pluginSolid2LayoutsLibrary = library.pluginSolid2LayoutsLibrary;
4 changes: 2 additions & 2 deletions packages/rsbuild-plugin-solid-layouts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rsbuild-plugin-solid-layouts",
"version": "0.1.4",
"version": "0.2.0",
"description": "Rsbuild integration for the Solid Layouts library and application compilers",
"license": "MIT",
"type": "module",
Expand All @@ -19,7 +19,7 @@
"index.d.ts"
],
"dependencies": {
"solid-layouts-oxc": "0.1.7"
"solid-layouts-oxc": "0.2.0"
},
"peerDependencies": {
"@rsbuild/core": ">=1.3.0"
Expand Down
13 changes: 13 additions & 0 deletions packages/solid-layouts-oxc/application.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export type SolidLayoutsApplicationOptions = {
layouts?: ApplicationLayoutSource[];
runtime?: string;
include?: string;
/**
* Which major of Solid the build targets. Defaults to 1. Prefer
* `pluginSolid2LayoutsApplication`, which sets it.
*/
solid?: 1 | 2;
};

export type ResolvedLayoutSource = {
Expand All @@ -30,6 +35,11 @@ export type CompiledApplication = {
};

export declare const APPLICATION_BOUNDARY: "solid-layouts/application-boundary";
export declare const SOLID_2_APPLICATION_BOUNDARY: "solid-layouts/solid-2/application-boundary";
export declare function boundaryFor(solid?: 1 | 2): {
specifier: string;
subpath: "." | "./solid-2";
};
export declare const FORMAT: "solid-layouts-library-v2";
export declare function compileApplication(
options?: SolidLayoutsApplicationOptions,
Expand All @@ -46,3 +56,6 @@ export declare function resolveLayoutSource(
export declare function pluginSolidLayoutsApplication(
options?: SolidLayoutsApplicationOptions,
): { name: string; enforce: "post"; setup(api: unknown): void };
export declare function pluginSolid2LayoutsApplication(
options?: Omit<SolidLayoutsApplicationOptions, "solid">,
): { name: string; enforce: "post"; setup(api: unknown): void };
66 changes: 59 additions & 7 deletions packages/solid-layouts-oxc/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ const { transform } = require("./index.js");

const FORMAT = "solid-layouts-library-v2";
const APPLICATION_BOUNDARY = "solid-layouts/application-boundary";
const SOLID_2_APPLICATION_BOUNDARY = "solid-layouts/solid-2/application-boundary";

/**
* The specifier a generated component imports its boundary from, and the
* `solid-layouts` subpath that specifier has to resolve to.
*
* Two entries because the runtime is published twice: Solid 2.0 moved
* `Dynamic` and `createComponent` out of `solid-js/web` and dropped that
* subpath, so one build cannot serve both majors. The specifier is spelled out
* in the generated file rather than resolved by a bundler condition, which
* means `grep` answers which runtime a build is on and a mismatch reads as a
* wrong-looking import rather than as a resolve failure two layers down.
*/
function boundaryFor(solid) {
if (solid === undefined || solid === 1) {
return { specifier: APPLICATION_BOUNDARY, subpath: "." };
}
if (solid === 2) {
return { specifier: SOLID_2_APPLICATION_BOUNDARY, subpath: "./solid-2" };
}
throw new Error(`unknown solid major: ${solid}`);
}

function readJson(path, label) {
try {
Expand Down Expand Up @@ -43,14 +65,23 @@ function requiredFile(packageRoot, path, label) {
return absolute;
}

function publicEntryFrom(packageJson) {
const rootExport = packageJson.exports?.["."];
function publicEntryFrom(packageJson, subpath = ".") {
const rootExport = packageJson.exports?.[subpath];
if (typeof rootExport === "string") return rootExport;
if (rootExport && typeof rootExport === "object") {
for (const condition of ["import", "default"]) {
if (typeof rootExport[condition] === "string") return rootExport[condition];
}
}
// Only the root entry has the legacy fields to fall back on. A named subpath
// that is missing is a version of the runtime the installed package does not
// publish, which is worth saying plainly rather than silently serving the
// wrong major from `main`.
if (subpath !== ".") {
throw new Error(
`${packageJson.name}@${packageJson.version} does not export ${subpath}; it predates Solid 2 support`,
);
}
for (const field of ["module", "main"]) {
if (typeof packageJson[field] === "string") return packageJson[field];
}
Expand Down Expand Up @@ -141,14 +172,18 @@ function publicSubpathSources(source) {
.sort((a, b) => a.module.localeCompare(b.module));
}

function resolvePublicPackageEntry(root, module) {
function resolvePublicPackageEntry(root, module, subpath = ".") {
const packageJsonPath = resolvePackageJson(root, module);
const packageRoot = dirname(packageJsonPath);
const packageJson = readJson(packageJsonPath, `${module} package metadata`);
if (packageJson.name !== module) {
throw new Error(`resolved package ${packageJson.name} does not match ${module}`);
}
return requiredFile(packageRoot, publicEntryFrom(packageJson), `${module} public entry`);
return requiredFile(
packageRoot,
publicEntryFrom(packageJson, subpath),
`${module} public entry`,
);
}

function validateComponent(module, packageRoot, name, component) {
Expand Down Expand Up @@ -286,8 +321,9 @@ function compileApplicationFile(source, filename, application) {
}

function pluginSolidLayoutsApplication(options = {}) {
const boundary = boundaryFor(options.solid);
return {
name: "solid-layouts:application",
name: options.solid === 2 ? "solid-layouts:application:solid-2" : "solid-layouts:application",
enforce: "post",
setup(api) {
const application = compileApplication({
Expand All @@ -296,13 +332,13 @@ function pluginSolidLayoutsApplication(options = {}) {
});
const runtime = options.runtime
? resolve(application.root, options.runtime)
: resolvePublicPackageEntry(application.root, "solid-layouts");
: resolvePublicPackageEntry(application.root, "solid-layouts", boundary.subpath);
if (!existsSync(runtime)) throw new Error(`solid-layouts runtime not found: ${runtime}`);

api.modifyBundlerChain({
order: "post",
handler(chain) {
chain.resolve.alias.set(APPLICATION_BOUNDARY, runtime);
chain.resolve.alias.set(boundary.specifier, runtime);
chain.module
.rule("solid-layouts-application")
.after("babel-js")
Expand All @@ -321,11 +357,27 @@ function pluginSolidLayoutsApplication(options = {}) {
};
}

/**
* The Solid 2.0 form of the application plugin.
*
* A separate exported name rather than an option the caller passes, because
* the choice is not independent of the rest of the build: it has to agree with
* `pluginSolid2()` and with the installed `solid-js`. A name that must match
* its neighbour in the plugin list is easier to get right, and easier to read
* back later, than a flag that must.
*/
function pluginSolid2LayoutsApplication(options = {}) {
return pluginSolidLayoutsApplication({ ...options, solid: 2 });
}

module.exports = {
APPLICATION_BOUNDARY,
FORMAT,
SOLID_2_APPLICATION_BOUNDARY,
boundaryFor,
compileApplication,
compileApplicationFile,
pluginSolid2LayoutsApplication,
pluginSolidLayoutsApplication,
resolveLayoutSource,
};
Loading
Loading