From d5f1042a6a0b300f44d4c8862e984bf549d97113 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:29:48 +0200 Subject: [PATCH 01/21] docs(spec): bundler-as-analyzer design for createT tree-shaking A Vite plugin that makes createT(THREE) tree-shakeable by letting the bundler itself compute the used-set: pass 1 rewrites the catalogue binding to a throwaway namespace scaffold and reads renderedExports after tree-shaking; pass 2 emits createT({ ...used keys with locally-resolved providers }), keeping createT in the output. Replaces the earlier ts-morph / alias-resolution-host / soundness-guard / unplugin approach, which reconstructed cross-file analysis the bundler already performs. Mechanism (cross-file union, sound deopt, custom literals, spread/override precedence) and the two-pass orchestration are spike-validated. --- .../2026-06-01-createt-narrowing-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-createt-narrowing-design.md diff --git a/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md b/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md new file mode 100644 index 00000000..e8610ac4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md @@ -0,0 +1,127 @@ +# solid-three catalogue narrowing — bundler-as-analyzer design + +**Date:** 2026-06-01 +**Status:** Design, spike-validated. Supersedes and replaces the earlier ts-morph / unplugin / alias-resolution / soundness-guard design (deleted). +**Scope:** A Vite plugin that makes `createT(THREE)` tree-shakeable, by letting the **bundler itself** compute which catalogue members are used. + +## The problem + +`solid-three` exposes a concise factory: `const T = createT(THREE)`, used as ``, ``, … `createT` returns an opaque runtime **Proxy** over the catalogue. Because `import * as THREE from "three"` flows into that proxy **as a whole value**, the bundler cannot see which classes are reached through `T`, so it retains *all* of three. Concise to write; terrible to bundle. + +The goal: ship only the three (or custom) classes actually reached through `T`, **without** changing the `createT` API or its dynamism. + +## The key insight + +Modern bundlers (Rollup, and Vite which builds on Rollup) **already** tree-shake namespace imports: `import * as NS from "x"; NS.Mesh` retains only `Mesh` — *as long as `NS` is never used as a whole value*. The proxy is the only thing defeating this. + +So instead of reimplementing cross-file usage analysis ourselves (the old ts-morph approach), we **borrow the bundler's**: transiently turn `T` into a real namespace, let the bundler tree-shake it, and read back which members survived. The bundler is the analyzer — and it is exact, whole-program, and handles dynamic imports, code-splitting, renames, and re-exports natively. + +### Division of labor + +- **"Which keys are used"** — the hard, cross-file question → **the bundler measures it.** It follows `T` across every module, takes the union of accessed members, and (critically) **deoptimizes soundly**: any dynamic access or escape makes it keep *everything*. +- **"What provides each key"** — the catalogue's contents → read **locally** from the `createT()` expression at the call site. A single expression; no cross-file resolution, no aliases, no module graph. This is the only analysis code we keep. + +## Validation (spikes, Rollup + Vite) + +All confirmed empirically before committing to the design: + +| Case | Result | +| --- | --- | +| Cross-file namespace access (`export * as T` and `import * as T; export {T}`) | tree-shakes; unused members dropped across the file boundary | +| Cross-file **union** (file A uses `T.A`, file B uses `T.B`) | exactly `A,B` kept; `C` dropped; `renderedExports = ["A","B"]` | +| **Dynamic** `T[expr]` | **keeps all** (sound deopt) | +| **Escape** (`T` passed to a function) | **keeps all** (sound deopt) | +| **Spread** `{...T}` | **keeps all** (sound deopt) | +| **Custom object literal** `createT({MyMesh, Other})`, only `T.MyMesh` used | baseline (opaque) retains all; narrowed emit drops the unused custom class | +| **Override / precedence** `createT({...THREE, Mesh: Custom})`, `T.Mesh`+`T.Group` used | emit `createT({Group: THREE.Group, Mesh: Custom})` → `THREE.Mesh` **dropped** (overridden), `THREE.Group` kept, `Custom` kept | + +The measurement signal is Rollup's `chunk.modules[id].renderedExports` (the exports that survived tree-shaking) — a first-class API, not a hack. + +## Architecture — two passes + +The plugin runs only on **production builds** (dev keeps the untouched runtime proxy). It keeps `createT` in the shipped output (debugging and runtime semantics unchanged — the namespace form is internal-only). That requires two passes: + +### Pass 1 — measure + +For each analyzable `createT()` call: + +1. Read `` locally to derive the **key universe** (all keys the catalogue *could* provide) — see provider-map below. +2. Rewrite the result binding into a **namespace import of a throwaway scaffold module**: `const T = createT(THREE)` → `import * as T from "\0solid-three:measure:"` (plus the original `export`, if any). The scaffold module (virtual, per call) has one droppable named export per key in the universe (`export const Mesh = 0`, …). +3. Build. In `generateBundle`, read `renderedExports` of each scaffold module → the **exact used-key set** for that call. + +The scaffold is never shipped; its only job is to make the bundler report usage. Its export *values* are irrelevant (they're droppable placeholders); only the export *names* matter. + +### Pass 2 — emit + +For each `createT()` call, rewrite `` to an explicit object containing only the used keys, each mapped to its locally-resolved provider: + +```js +// createT(THREE), used = {Mesh, Group} +createT({ Mesh: THREE.Mesh, Group: THREE.Group }) +``` + +`createT` stays in the output; runtime shape (proxy/object/identity — whatever `createT` does) is unchanged. The bundler then tree-shakes three down to the referenced members. + +## The provider-map (the only local analysis we keep) + +Reading `` at the call site yields, for each key, the expression that provides it. Rules (v1's logic, re-validated): + +| `` shape | key universe | provider for key `K` | +| --- | --- | --- | +| namespace import `THREE` (`import * as THREE from "three"`) | the module's exports | `THREE.K` | +| custom namespace (`import * as W from "three/webgpu"`) | that module's exports | `W.K` | +| object literal `{ K: expr, … }` | the literal's keys | `expr` | +| spread `{ ...THREE, K: expr }` | union of namespace + explicit keys | **last writer wins** (walk in source order) | +| getter/method `{ get K(){…} }` | `K` | the getter/method, **copied verbatim** | + +**Bail (leave `createT(...)` completely untouched → full, dynamic, sound):** + +- `createT(store)` / `createT(someVar)` — key set not statically enumerable. +- `createT({ [computed]: x })` — computed key. +- `createT({ ...someRuntimeObject })` — spread of a non-namespace value. + +A usage-site dynamic access (`T[expr]`) is **not** a bail — the bundler deopts that catalogue to keep-all in pass 1, so it measures as fully-used and pass 2 emits the full set. Sound, just unoptimized. + +Deriving the key universe for a namespace requires reading that module's export names: for `three`/`three/webgpu` (node-resolvable) we enumerate exports directly; for a custom namespace module we resolve and read its exports (one module, via the bundler — not a graph walk). + +## Soundness + +Narrowing ships only the keys the bundler reports as used. The bundler reports exactly what survived tree-shaking, and **deopts to keep-all on any access it cannot statically resolve** — so it never under-reports usage for reachable code. The only condition is that the measurement build and the real build see the **same module graph**; they do, because the measurement build uses the **same resolved Vite config and entries**, and the namespace rewrite touches only the `createT` call sites (it does not change which app modules are reachable). The deopt direction is always *over-retain*, never *under-retain* — so a broken bundle cannot ship. + +No guard, no completeness proof, no alias replication: soundness is a property of the bundler's own tree-shaking, which we measure rather than reconstruct. + +## Orchestration + +The two passes run inside one plugin invocation: + +- `configResolved(config)` captures the resolved config (root, entries, plugins). +- `buildStart` of the real build triggers a **measurement sub-build** via Vite's JS API with the *same resolved config* plus a measure-mode flag (apply the pass-1 namespace rewrite, `write: false`, capture `renderedExports` in `generateBundle`). A re-entrancy guard prevents the sub-build from recursing. +- The captured used-key sets are handed to the real build's `transform`, which applies the pass-2 emit. + +Cost: ~2× build, production-only. This is the one piece of real engineering left — and it is far smaller than the deleted ts-morph + resolution-host + guard surface. + +Parsing the `createT` argument needs a TS/JSX-aware parse of a *single module* (to find the call and read ``) — not a project Program. Options: the bundler's `this.parse`, or a small standalone parse (oxc/acorn-with-jsx/babel). To be settled in the plan. + +## What this deletes (vs the old design) + +Gone: ts-morph whole-project `Program`, `findReferencesAsNodes` symbol following, the source-glob discovery, the custom resolution host, Vite `resolve.alias` replication, the resolution-agreement guard, and **unplugin** (Vite-only now). The cross-file engine is replaced by the bundler; only the local provider-map reader and the two-pass orchestration remain. + +## Non-goals + +- No change to the `createT` runtime API or its dynamism. Dynamic catalogues stay fully dynamic (we bail). +- No cross-bundler support. Vite only. (The mechanism is Rollup-native, so a Rollup plugin is a later possibility, but not a goal.) +- No dev-mode narrowing — dev keeps the runtime proxy. + +## Open questions + +- **Orchestration details:** nested `build()` ergonomics, re-entrancy flag mechanism, and threading `renderedExports` from sub-build to real build. The main thing to prototype. +- **Multiple `createT` calls / multiple catalogues:** one scaffold + used-set per call; confirm per-call isolation in the measurement build. +- **`createT` argument parser:** `this.parse` vs a bundled parser; must handle TS/JSX at `enforce: "pre"`. +- **2× build cost:** acceptable for production? Possible mitigations (lighter measure build: no minify, skip non-essential plugins) — but the measure graph must match the real graph, so prune carefully. +- **Single-pass fast path (deferred):** if the wrapped-namespace form were ever an acceptable shipped runtime, `createT(THREE)` could skip pass 2 entirely. Rejected for now (debugging clarity; keep `createT`), but the mechanism supports it. + +## Test strategy + +- The spikes become **fixtures**: real `vite build` of small consumer projects asserting the right classes drop/survive, plus the deopt cases (dynamic/escape/spread → keep-all) and the override/precedence case. +- The browser **soundness oracle** (mount a scene through the mocked `solid-three`, assert every class requested at runtime is in the narrowed catalogue) remains the release gate. +- Unit-test the local provider-map reader (namespace / literal / spread-precedence / getter-verbatim / bail cases) directly. From d534b7b42b9c1b0fb46c699df64cce6fa354d94a Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:39:57 +0200 Subject: [PATCH 02/21] docs(plan): implementation plan for vite-plugin-solid-three Bite-sized TDD tasks for the bundler-as-analyzer design: single-file createT analysis (parse with ts.createSourceFile, no Program), provider-map with last-write-wins, throwaway measurement scaffold, and the two-pass measure/emit orchestration. Pure layers (analyze/providers/rewrite/scaffold) are unit-tested; the orchestration and catalogue shapes are covered by real vite-build fixtures; the browser oracle is the release gate. --- .../2026-06-01-vite-plugin-solid-three.md | 1168 +++++++++++++++++ 1 file changed, 1168 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-vite-plugin-solid-three.md diff --git a/docs/superpowers/plans/2026-06-01-vite-plugin-solid-three.md b/docs/superpowers/plans/2026-06-01-vite-plugin-solid-three.md new file mode 100644 index 00000000..82d281bb --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-vite-plugin-solid-three.md @@ -0,0 +1,1168 @@ +# vite-plugin-solid-three Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Vite plugin that makes `createT(THREE)` tree-shakeable, by letting the bundler measure which catalogue keys are used (pass 1) and re-emitting `createT` narrowed to those keys (pass 2). + +**Architecture:** Two passes inside one Vite plugin. The *analysis is single-file* — find `createT()` in the catalog module, read its argument locally (namespace / object-literal / bail). Pass 1 rewrites the result binding into a namespace import of a throwaway scaffold; a nested measurement `build()` tree-shakes it and `renderedExports` reports the used keys. Pass 2 rewrites `` to `createT({ …used keys with locally-resolved providers })`. The bundler does all cross-file work. + +**Tech Stack:** Vite 6 (peer), TypeScript 5 parser (`ts.createSourceFile`, peer), magic-string, vitest (Node + browser-oracle). No ts-morph, no unplugin, no glob, no resolution host, no guard. + +**Design source:** `docs/superpowers/specs/2026-06-01-createt-narrowing-design.md` + +**Spike-validated:** cross-file namespace tree-shaking, sound deopt, custom literals, spread/override precedence, and the two-pass orchestration (one nested build, no recursion, `renderedExports` captured and fed back). + +--- + +## Core data model (used across tasks — defined in Task 2, referenced everywhere) + +```ts +// A catalogue is an ordered list of sources (last writer wins on key collisions). +export type CatalogueSource = + | { kind: "namespace"; localName: string; moduleId: string } // createT(THREE) or {...THREE} + | { kind: "entry"; key: string; valueText: string } // { Mesh: X } or { get Foo(){…} } (verbatim) + +export interface CatalogueSite { + binding: string // result binding name, e.g. "T" + exported: boolean // was the binding statement `export`ed + sources: CatalogueSource[] // empty is impossible for a non-bail site + // char offsets into the module source: + argStart: number // start of the createT argument + argEnd: number // end of the createT argument + statementStart: number // start of `const T = createT(...)` (or `export const ...`) + statementEnd: number // end of that statement + siteIndex: number // 0-based index of this createT site within the module +} + +export interface BailSite { + siteIndex: number + reason: string +} + +export interface ModuleAnalysis { + sites: CatalogueSite[] + bails: BailSite[] +} +``` + +## File structure + +- `packages/vite-plugin-solid-three/src/types.ts` — the model above. +- `packages/vite-plugin-solid-three/src/analyze.ts` — `analyzeModule(code, id): ModuleAnalysis` (single-file ts parse). +- `packages/vite-plugin-solid-three/src/scaffold.ts` — `scaffoldSource(keys): string`; scaffold id encode/decode helpers. +- `packages/vite-plugin-solid-three/src/namespace-keys.ts` — `enumerateNamespaceKeys(moduleId, root): Promise`. +- `packages/vite-plugin-solid-three/src/rewrite.ts` — `rewriteMeasure(code, analysis, scaffoldIdFor)` and `rewriteEmit(code, analysis, usedKeysFor, keyUniverseFor)`. +- `packages/vite-plugin-solid-three/src/index.ts` — the Vite plugin + two-pass orchestration. +- `packages/vite-plugin-solid-three/test/**` — unit tests + `fixtures/` (real vite builds) + `oracle/` (browser gate). + +--- + +## Task 1: Scaffold the package + +**Files:** +- Create: `packages/vite-plugin-solid-three/package.json` +- Create: `packages/vite-plugin-solid-three/tsconfig.json` +- Create: `packages/vite-plugin-solid-three/vitest.config.ts` +- Create: `packages/vite-plugin-solid-three/src/index.ts` (placeholder) + +- [ ] **Step 1: package.json** + +```json +{ + "name": "vite-plugin-solid-three", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }, + "files": ["dist/**"], + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "test": "vitest run", + "test:oracle": "vitest run --config vitest.browser.config.ts", + "test:ts": "tsc --noEmit -p tsconfig.json && vitest run" + }, + "dependencies": { "magic-string": "^0.30.0" }, + "peerDependencies": { "typescript": ">=5.0.0", "vite": "^5 || ^6" }, + "devDependencies": { + "@solidjs/testing-library": "^0.8.8", + "@vitest/browser": "^4.1.7", + "@vitest/browser-playwright": "^4.1.7", + "playwright": "^1.60.0", + "solid-js": "^1.8.17", + "solid-three": "workspace:*", + "three": "^0.181.2", + "tsup": "^8.0.2", + "typescript": "^5.4.5", + "vite": "6.4.2", + "vite-plugin-solid": "2.11.12", + "vitest": "^4.1.7" + } +} +``` + +- [ ] **Step 2: tsconfig.json** + +```json +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src", "test"] +} +``` + +- [ ] **Step 3: vitest.config.ts** (Node unit + fixture builds) + +```ts +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + exclude: ["test/oracle/**"], + environment: "node", + // real vite builds in fixtures are heavy; serialize and give headroom. + fileParallelism: false, + testTimeout: 60_000, + }, +}) +``` + +- [ ] **Step 4: placeholder src/index.ts** + +```ts +export const placeholder = true +``` + +- [ ] **Step 5: install + commit** + +Run: `cd packages/vite-plugin-solid-three && pnpm install` +Expected: workspace links resolve. + +```bash +git add packages/vite-plugin-solid-three +git commit -m "chore(vps3): scaffold vite-plugin-solid-three package" +``` + +--- + +## Task 2: Types + recognize `createT` calls and their result binding + +**Files:** +- Create: `packages/vite-plugin-solid-three/src/types.ts` (the model above) +- Create: `packages/vite-plugin-solid-three/src/analyze.ts` +- Test: `packages/vite-plugin-solid-three/test/analyze.test.ts` + +This task finds `createT(...)` calls (imported from `solid-three`, rename-aware) and records the result binding + statement offsets. Argument classification comes in Tasks 3–5. + +- [ ] **Step 1: write the failing test** + +```ts +import { describe, expect, it } from "vitest" +import { analyzeModule } from "../src/analyze.ts" + +describe("analyzeModule — call recognition", () => { + it("finds createT, the binding, and export flag", () => { + const code = `import { createT } from "solid-three" +import * as THREE from "three" +export const T = createT(THREE)` + const { sites } = analyzeModule(code, "/catalog.ts") + expect(sites).toHaveLength(1) + expect(sites[0].binding).toBe("T") + expect(sites[0].exported).toBe(true) + expect(code.slice(sites[0].argStart, sites[0].argEnd)).toBe("THREE") + }) + + it("follows a renamed createT import", () => { + const code = `import { createT as mk } from "solid-three" +import * as THREE from "three" +const T = mk(THREE)` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites).toHaveLength(1) + expect(sites[0].binding).toBe("T") + expect(sites[0].exported).toBe(false) + }) + + it("ignores createT-looking calls not imported from solid-three", () => { + const code = `function createT(x){return x} +const T = createT({})` + expect(analyzeModule(code, "/c.ts").sites).toHaveLength(0) + }) +}) +``` + +- [ ] **Step 2: run — fails (module missing)** + +Run: `pnpm vitest run test/analyze.test.ts -t "call recognition"` +Expected: FAIL. + +- [ ] **Step 3: implement types.ts** — exactly the "Core data model" block above. + +- [ ] **Step 4: implement analyze.ts** (call recognition only; argument left empty for now) + +```ts +import ts from "typescript" +import type { CatalogueSite, ModuleAnalysis } from "./types.ts" + +const SOLID_THREE = "solid-three" +const FACTORY = "createT" + +/** Local names that `createT` is bound to via `import { createT [as x] } from "solid-three"`. */ +function factoryAliases(sf: ts.SourceFile): Set { + const names = new Set() + sf.forEachChild(node => { + if (!ts.isImportDeclaration(node)) return + if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== SOLID_THREE) return + const named = node.importClause?.namedBindings + if (named && ts.isNamedImports(named)) { + for (const el of named.elements) { + if ((el.propertyName?.text ?? el.name.text) === FACTORY) names.add(el.name.text) + } + } + }) + return names +} + +export function analyzeModule(code: string, fileName: string): ModuleAnalysis { + const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const aliases = factoryAliases(sf) + const sites: CatalogueSite[] = [] + if (aliases.size === 0) return { sites, bails: [] } + + let siteIndex = 0 + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + aliases.has(node.expression.text) + ) { + const site = siteFromCall(node, sf, siteIndex) + if (site) { + sites.push(site) + siteIndex++ + } + } + ts.forEachChild(node, visit) + } + visit(sf) + return { sites, bails: [] } +} + +/** Build a partial site from a createT call: binding + statement offsets. Arg sources filled in later tasks. */ +function siteFromCall(call: ts.CallExpression, sf: ts.SourceFile, siteIndex: number): CatalogueSite | undefined { + const arg = call.arguments[0] + if (!arg) return undefined + + // result binding: `const T = createT(...)` (optionally exported) + const varDecl = call.parent + if (!ts.isVariableDeclaration(varDecl) || !ts.isIdentifier(varDecl.name)) return undefined + const varList = varDecl.parent + const statement = varList.parent + if (!ts.isVariableStatement(statement)) return undefined + const exported = !!statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) + + return { + binding: varDecl.name.text, + exported, + sources: [], // filled in Tasks 3–5 + argStart: arg.getStart(sf), + argEnd: arg.getEnd(), + statementStart: statement.getStart(sf), + statementEnd: statement.getEnd(), + siteIndex, + } +} +``` + +- [ ] **Step 5: run — passes** + +Run: `pnpm vitest run test/analyze.test.ts -t "call recognition"` +Expected: PASS (3). + +- [ ] **Step 6: commit** + +```bash +git add packages/vite-plugin-solid-three/src/types.ts packages/vite-plugin-solid-three/src/analyze.ts packages/vite-plugin-solid-three/test/analyze.test.ts +git commit -m "feat(vps3): recognize createT calls and result binding (single-file)" +``` + +--- + +## Task 3: Classify a namespace argument (`createT(THREE)`) + +**Files:** +- Modify: `packages/vite-plugin-solid-three/src/analyze.ts` +- Test: `packages/vite-plugin-solid-three/test/analyze.test.ts` (append) + +- [ ] **Step 1: failing test** + +```ts +describe("analyzeModule — namespace arg", () => { + it("records createT(THREE) as a namespace source pointing at the module", () => { + const code = `import { createT } from "solid-three" +import * as THREE from "three" +export const T = createT(THREE)` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites[0].sources).toEqual([{ kind: "namespace", localName: "THREE", moduleId: "three" }]) + }) + + it("bails when the arg is a namespace alias it cannot resolve to an import", () => { + const code = `import { createT } from "solid-three" +const T = createT(SOMETHING)` + const { sites, bails } = analyzeModule(code, "/c.ts") + expect(sites).toHaveLength(0) + expect(bails[0].reason).toMatch(/not.*namespace|unresolved/i) + }) +}) +``` + +- [ ] **Step 2: run — fails** (`sources` is empty). + +- [ ] **Step 3: implement** — add a namespace-import index and argument classification. In `analyzeModule`, build a map of `import * as NS from "mod"`; pass it to `siteFromCall`. Add: + +```ts +/** local namespace name -> module specifier, from `import * as NS from "mod"`. */ +function namespaceImports(sf: ts.SourceFile): Map { + const map = new Map() + sf.forEachChild(node => { + if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return + const named = node.importClause?.namedBindings + if (named && ts.isNamespaceImport(named)) map.set(named.name.text, node.moduleSpecifier.text) + }) + return map +} +``` + +Wire it through (`analyzeModule` builds `const namespaces = namespaceImports(sf)` and passes to `siteFromCall`). Then classify the argument inside `siteFromCall`, replacing the `sources: []` placeholder with a call to `classifyArg(arg, namespaces, sf)`, and have `siteFromCall` return either a site or push a bail. Refactor `siteFromCall` to return `CatalogueSite | { bail: string }`: + +```ts +function classifyArg( + arg: ts.Expression, + namespaces: Map, + sf: ts.SourceFile, +): { sources: CatalogueSource[] } | { bail: string } { + // createT(THREE) + if (ts.isIdentifier(arg)) { + const moduleId = namespaces.get(arg.text) + if (moduleId) return { sources: [{ kind: "namespace", localName: arg.text, moduleId }] } + return { bail: `argument ${arg.text} is not a resolvable namespace import` } + } + // object literals handled in Task 4 + if (ts.isObjectLiteralExpression(arg)) return classifyObject(arg, namespaces, sf) + return { bail: `unsupported catalogue argument (${ts.SyntaxKind[arg.kind]})` } +} +``` + +Update `analyzeModule`'s visit to push to `bails` when `siteFromCall` returns a bail (carry `siteIndex`, increment on every recognized call). Add a stub `classifyObject` returning `{ bail: "object literals — Task 4" }` for now so it compiles. + +- [ ] **Step 4: run — passes** + +Run: `pnpm vitest run test/analyze.test.ts -t "namespace arg"` +Expected: PASS. + +- [ ] **Step 5: commit** + +```bash +git add packages/vite-plugin-solid-three/src/analyze.ts packages/vite-plugin-solid-three/test/analyze.test.ts +git commit -m "feat(vps3): classify namespace catalogue argument" +``` + +--- + +## Task 4: Classify object-literal arguments (entries, spreads, getters) + +**Files:** +- Modify: `packages/vite-plugin-solid-three/src/analyze.ts` +- Test: `packages/vite-plugin-solid-three/test/analyze.test.ts` (append) + +- [ ] **Step 1: failing test** + +```ts +describe("analyzeModule — object arg", () => { + it("reads explicit entries with verbatim value text", () => { + const code = `import { createT } from "solid-three" +import { MyMesh } from "./x" +export const T = createT({ Mesh: MyMesh })` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites[0].sources).toEqual([{ kind: "entry", key: "Mesh", valueText: "MyMesh" }]) + }) + + it("reads a namespace spread then an overriding entry, in order", () => { + const code = `import { createT } from "solid-three" +import * as THREE from "three" +import { Custom } from "./x" +export const T = createT({ ...THREE, Mesh: Custom })` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites[0].sources).toEqual([ + { kind: "namespace", localName: "THREE", moduleId: "three" }, + { kind: "entry", key: "Mesh", valueText: "Custom" }, + ]) + }) + + it("keeps a getter verbatim", () => { + const code = `import { createT } from "solid-three" +export const T = createT({ get Foo(){ return Date.now() > 0 ? A : B } })` + const { sites } = analyzeModule(code, "/c.ts") + const src = sites[0].sources[0] + expect(src).toMatchObject({ kind: "entry", key: "Foo" }) + expect((src as any).valueText).toContain("get Foo()") + }) +}) +``` + +- [ ] **Step 2: run — fails** (object bails via stub). + +- [ ] **Step 3: implement `classifyObject`** (replace the stub) + +```ts +function classifyObject( + obj: ts.ObjectLiteralExpression, + namespaces: Map, + sf: ts.SourceFile, +): { sources: CatalogueSource[] } | { bail: string } { + const sources: CatalogueSource[] = [] + for (const prop of obj.properties) { + if (ts.isSpreadAssignment(prop)) { + if (!ts.isIdentifier(prop.expression)) return { bail: "spread of a non-identifier" } + const moduleId = namespaces.get(prop.expression.text) + if (!moduleId) return { bail: `spread of non-namespace ${prop.expression.text}` } + sources.push({ kind: "namespace", localName: prop.expression.text, moduleId }) + continue + } + // computed keys are not statically enumerable + const nameNode = + ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop) || + ts.isGetAccessorDeclaration(prop) || ts.isMethodDeclaration(prop) + ? prop.name + : undefined + if (!nameNode) return { bail: "unsupported property" } + if (ts.isComputedPropertyName(nameNode)) return { bail: "computed property key" } + const key = ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) ? nameNode.text : undefined + if (key === undefined) return { bail: "non-static property key" } + + if (ts.isPropertyAssignment(prop)) { + sources.push({ kind: "entry", key, valueText: prop.initializer.getText(sf) }) + } else if (ts.isShorthandPropertyAssignment(prop)) { + sources.push({ kind: "entry", key, valueText: key }) + } else if (ts.isGetAccessorDeclaration(prop) || ts.isMethodDeclaration(prop)) { + sources.push({ kind: "entry", key, valueText: prop.getText(sf) }) // verbatim getter/method + } else { + return { bail: "unsupported property" } + } + } + if (sources.length === 0) return { bail: "empty catalogue" } + return { sources } +} +``` + +- [ ] **Step 4: run — passes** + +Run: `pnpm vitest run test/analyze.test.ts -t "object arg"` +Expected: PASS. + +- [ ] **Step 5: commit** + +```bash +git add packages/vite-plugin-solid-three/src/analyze.ts packages/vite-plugin-solid-three/test/analyze.test.ts +git commit -m "feat(vps3): classify object-literal catalogue (entries, spreads, getters)" +``` + +--- + +## Task 5: Bail cases (dynamic source / computed key / unknown spread) + +**Files:** +- Test: `packages/vite-plugin-solid-three/test/analyze.test.ts` (append) + +Implementation already covers these (Tasks 3–4); this task locks them with tests. + +- [ ] **Step 1: failing/confirming test** + +```ts +describe("analyzeModule — bails", () => { + it.each([ + [`const T = createT(store)`, /not a resolvable namespace/i], + [`const T = createT({ [k]: X })`, /computed property/i], + [`const T = createT({ ...runtimeObj })`, /non-namespace/i], + [`const T = createT(makeIt())`, /unsupported catalogue argument/i], + ])("bails on %s", (body, reason) => { + const code = `import { createT } from "solid-three"\n${body}` + const { sites, bails } = analyzeModule(code, "/c.ts") + expect(sites).toHaveLength(0) + expect(bails[0].reason).toMatch(reason) + }) +}) +``` + +- [ ] **Step 2: run — passes** (or fix any mismatched bail message in analyze.ts to satisfy the regexes). + +Run: `pnpm vitest run test/analyze.test.ts` +Expected: PASS (all analyze tests). + +- [ ] **Step 3: typecheck + commit** + +Run: `pnpm tsc --noEmit -p tsconfig.json` + +```bash +git add packages/vite-plugin-solid-three/test/analyze.test.ts +git commit -m "test(vps3): lock catalogue bail cases" +``` + +--- + +## Task 6: Scaffold module generator + id codec + +**Files:** +- Create: `packages/vite-plugin-solid-three/src/scaffold.ts` +- Test: `packages/vite-plugin-solid-three/test/scaffold.test.ts` + +The measurement scaffold is one droppable named export per key. Each scaffold module's id encodes the source module + site index, so `generateBundle` can map `renderedExports` back. + +- [ ] **Step 1: failing test** + +```ts +import { describe, expect, it } from "vitest" +import { scaffoldSource, encodeScaffoldId, decodeScaffoldId, isScaffoldId } from "../src/scaffold.ts" + +describe("scaffold", () => { + it("emits one droppable named export per key", () => { + expect(scaffoldSource(["Mesh", "Group"])).toBe("export const Mesh = 0\nexport const Group = 0\n") + }) + + it("round-trips an id (module + site index)", () => { + const id = encodeScaffoldId("/abs/catalog.ts", 2) + expect(isScaffoldId(id)).toBe(true) + expect(decodeScaffoldId(id)).toEqual({ moduleId: "/abs/catalog.ts", siteIndex: 2 }) + }) +}) +``` + +- [ ] **Step 2: run — fails.** + +- [ ] **Step 3: implement scaffold.ts** + +```ts +const PREFIX = "\0vps3-scaffold:" + +export function scaffoldSource(keys: string[]): string { + return keys.map(k => `export const ${k} = 0\n`).join("") +} + +export function encodeScaffoldId(moduleId: string, siteIndex: number): string { + return `${PREFIX}${siteIndex}:${encodeURIComponent(moduleId)}` +} + +export function isScaffoldId(id: string): boolean { + return id.startsWith(PREFIX) +} + +export function decodeScaffoldId(id: string): { moduleId: string; siteIndex: number } { + const rest = id.slice(PREFIX.length) + const colon = rest.indexOf(":") + return { siteIndex: Number(rest.slice(0, colon)), moduleId: decodeURIComponent(rest.slice(colon + 1)) } +} +``` + +- [ ] **Step 4: run — passes. Step 5: commit** + +```bash +git add packages/vite-plugin-solid-three/src/scaffold.ts packages/vite-plugin-solid-three/test/scaffold.test.ts +git commit -m "feat(vps3): scaffold module generator and id codec" +``` + +--- + +## Task 7: Enumerate a namespace module's export names + +**Files:** +- Create: `packages/vite-plugin-solid-three/src/namespace-keys.ts` +- Test: `packages/vite-plugin-solid-three/test/namespace-keys.test.ts` + +For a namespace catalogue we need the full key universe (so the scaffold has every possible key). We get it by importing the module in Node and reading its exports, resolved from the project root. Cached per (moduleId, root). + +- [ ] **Step 1: failing test** (uses the real `three` dev dep) + +```ts +import { describe, expect, it } from "vitest" +import { enumerateNamespaceKeys } from "../src/namespace-keys.ts" + +describe("enumerateNamespaceKeys", () => { + it("returns three's class names including Mesh and BoxGeometry", async () => { + const keys = await enumerateNamespaceKeys("three", process.cwd()) + expect(keys).toContain("Mesh") + expect(keys).toContain("BoxGeometry") + expect(keys).not.toContain("default") + }) +}) +``` + +- [ ] **Step 2: run — fails.** + +- [ ] **Step 3: implement namespace-keys.ts** + +```ts +import { createRequire } from "node:module" +import { pathToFileURL } from "node:url" + +const cache = new Map>() + +/** Export names of a module, resolved from `root`, excluding `default` and internals. */ +export function enumerateNamespaceKeys(moduleId: string, root: string): Promise { + const cacheKey = `${root}\0${moduleId}` + let pending = cache.get(cacheKey) + if (!pending) { + pending = load(moduleId, root) + cache.set(cacheKey, pending) + } + return pending +} + +async function load(moduleId: string, root: string): Promise { + const require = createRequire(pathToFileURL(`${root}/package.json`)) + const resolved = require.resolve(moduleId) + const ns = await import(pathToFileURL(resolved).href) + return Object.keys(ns).filter(k => k !== "default" && !k.startsWith("__")) +} +``` + +- [ ] **Step 4: run — passes. Step 5: commit** + +```bash +git add packages/vite-plugin-solid-three/src/namespace-keys.ts packages/vite-plugin-solid-three/test/namespace-keys.test.ts +git commit -m "feat(vps3): enumerate namespace module export names" +``` + +--- + +## Task 8: Provider + key-universe helpers (pure, from sources + used keys) + +**Files:** +- Create: `packages/vite-plugin-solid-three/src/providers.ts` +- Test: `packages/vite-plugin-solid-three/test/providers.test.ts` + +Given a site's `sources` plus the resolved namespace key-sets, compute (a) the key universe (for the scaffold) and (b) the provider expression for each used key (last writer wins). + +- [ ] **Step 1: failing test** + +```ts +import { describe, expect, it } from "vitest" +import { keyUniverse, providerFor } from "../src/providers.ts" +import type { CatalogueSource } from "../src/types.ts" + +const nsKeys = new Map([["three", ["Mesh", "Group", "Box"]]]) +const sources: CatalogueSource[] = [ + { kind: "namespace", localName: "THREE", moduleId: "three" }, + { kind: "entry", key: "Mesh", valueText: "Custom" }, +] + +describe("providers", () => { + it("key universe = union of namespace keys and entry keys", () => { + expect([...keyUniverse(sources, nsKeys)].sort()).toEqual(["Box", "Group", "Mesh"]) + }) + it("last writer wins: Mesh -> Custom, Group -> THREE.Group", () => { + expect(providerFor("Mesh", sources, nsKeys)).toBe("Custom") + expect(providerFor("Group", sources, nsKeys)).toBe("THREE.Group") + }) + it("returns undefined for a key no source provides", () => { + expect(providerFor("Nope", sources, nsKeys)).toBeUndefined() + }) +}) +``` + +- [ ] **Step 2: run — fails. Step 3: implement providers.ts** + +```ts +import type { CatalogueSource } from "./types.ts" + +type NsKeys = Map // moduleId -> export names + +export function keyUniverse(sources: CatalogueSource[], nsKeys: NsKeys): Set { + const keys = new Set() + for (const s of sources) { + if (s.kind === "namespace") for (const k of nsKeys.get(s.moduleId) ?? []) keys.add(k) + else keys.add(s.key) + } + return keys +} + +/** The provider expression for `key`, applying last-writer-wins over sources. */ +export function providerFor(key: string, sources: CatalogueSource[], nsKeys: NsKeys): string | undefined { + for (let i = sources.length - 1; i >= 0; i--) { + const s = sources[i] + if (s.kind === "entry" && s.key === key) return s.valueText + if (s.kind === "namespace" && (nsKeys.get(s.moduleId) ?? []).includes(key)) return `${s.localName}.${key}` + } + return undefined +} +``` + +- [ ] **Step 4: run — passes. Step 5: commit** + +```bash +git add packages/vite-plugin-solid-three/src/providers.ts packages/vite-plugin-solid-three/test/providers.test.ts +git commit -m "feat(vps3): key-universe and last-write-wins provider resolution" +``` + +--- + +## Task 9: Rewrites — measure (pass 1) and emit (pass 2) + +**Files:** +- Create: `packages/vite-plugin-solid-three/src/rewrite.ts` +- Test: `packages/vite-plugin-solid-three/test/rewrite.test.ts` + +Both rewrites use magic-string against the analysis offsets, preserving sourcemaps. + +- [ ] **Step 1: failing test** + +```ts +import { describe, expect, it } from "vitest" +import { analyzeModule } from "../src/analyze.ts" +import { rewriteMeasure, rewriteEmit } from "../src/rewrite.ts" + +const CODE = `import { createT } from "solid-three" +import * as THREE from "three" +export const T = createT(THREE)` + +describe("rewriteMeasure (pass 1)", () => { + it("replaces the binding statement with a namespace import + re-export", () => { + const { sites } = analyzeModule(CODE, "/c.ts") + const out = rewriteMeasure(CODE, sites, () => "\0scaffold").code + expect(out).toContain(`import * as T from "\0scaffold"`) + expect(out).toContain(`export { T }`) + expect(out).not.toContain("createT(THREE)") + }) +}) + +describe("rewriteEmit (pass 2)", () => { + it("narrows the createT argument to used keys with resolved providers", () => { + const { sites } = analyzeModule(CODE, "/c.ts") + const nsKeys = new Map([["three", ["Mesh", "Group", "Box"]]]) + const out = rewriteEmit(CODE, sites, () => new Set(["Mesh", "Group"]), nsKeys).code + expect(out).toContain("createT({ Mesh: THREE.Mesh, Group: THREE.Group })") + expect(out).not.toContain("createT(THREE)") + }) +}) +``` + +- [ ] **Step 2: run — fails. Step 3: implement rewrite.ts** + +```ts +import MagicString from "magic-string" +import { keyUniverse, providerFor } from "./providers.ts" +import type { CatalogueSite } from "./types.ts" + +export interface RewriteResult { + code: string + map: ReturnType +} + +/** Pass 1: turn each catalogue binding into a namespace import of its scaffold. */ +export function rewriteMeasure( + code: string, + sites: CatalogueSite[], + scaffoldIdFor: (site: CatalogueSite) => string, +): RewriteResult { + const s = new MagicString(code) + for (const site of sites) { + const reexport = site.exported ? `\nexport { ${site.binding} }` : "" + s.overwrite( + site.statementStart, + site.statementEnd, + `import * as ${site.binding} from ${JSON.stringify(scaffoldIdFor(site))}${reexport}`, + ) + } + return { code: s.toString(), map: s.generateMap({ hires: true }) } +} + +/** Pass 2: narrow each createT argument to the used keys, resolving providers. */ +export function rewriteEmit( + code: string, + sites: CatalogueSite[], + usedKeysFor: (site: CatalogueSite) => Set, + nsKeys: Map, +): RewriteResult { + const s = new MagicString(code) + for (const site of sites) { + const used = usedKeysFor(site) + const universe = keyUniverse(site.sources, nsKeys) + const entries: string[] = [] + for (const key of used) { + if (!universe.has(key)) continue // accessed key not in catalogue — leave to runtime (proxy semantics) + const provider = providerFor(key, site.sources, nsKeys) + if (provider !== undefined) entries.push(`${key}: ${provider}`) + } + s.overwrite(site.argStart, site.argEnd, `{ ${entries.join(", ")} }`) + } + return { code: s.toString(), map: s.generateMap({ hires: true }) } +} +``` + +- [ ] **Step 4: run — passes. Step 5: commit** + +```bash +git add packages/vite-plugin-solid-three/src/rewrite.ts packages/vite-plugin-solid-three/test/rewrite.test.ts +git commit -m "feat(vps3): measure (pass 1) and emit (pass 2) rewrites" +``` + +--- + +## Task 10: The Vite plugin + two-pass orchestration + +**Files:** +- Modify: `packages/vite-plugin-solid-three/src/index.ts` +- Test (integration): `packages/vite-plugin-solid-three/test/fixtures/_harness.ts` + `test/fixtures/namespace/*` + `test/fixtures/fixtures.test.ts` + +This is the de-risk task — productionize the spiked orchestration with real config capture. Module-level state coordinates the real build and its nested measurement build (re-entrancy via a flag; results via a shared map). + +- [ ] **Step 1: implement index.ts** + +```ts +import { build, type Plugin, type UserConfig } from "vite" +import { analyzeModule } from "./analyze.ts" +import { enumerateNamespaceKeys } from "./namespace-keys.ts" +import { keyUniverse } from "./providers.ts" +import { rewriteEmit, rewriteMeasure } from "./rewrite.ts" +import { decodeScaffoldId, encodeScaffoldId, isScaffoldId, scaffoldSource } from "./scaffold.ts" + +// Module-level coordination between the real build and its nested measure build. +let measuring = false +// moduleId -> siteIndex -> used keys (filled by the measure build, read by the real build). +const measured = new Map>>() + +function normalize(id: string): string { + return id.split("?")[0] +} + +export default function solidThree(): Plugin { + let userConfig: UserConfig = {} + let root = process.cwd() + // moduleId -> siteIndex -> scaffold key universe (built during the measure transform). + const scaffoldKeys = new Map>() + + return { + name: "vite-plugin-solid-three", + enforce: "pre", + apply: "build", // dev keeps the runtime proxy + + config(config) { + userConfig = config + }, + configResolved(resolved) { + root = resolved.root + }, + + async buildStart() { + if (measuring) return // we ARE the measurement build — don't recurse + measuring = true + try { + await build({ + ...userConfig, + configFile: false, + logLevel: "silent", + build: { ...userConfig.build, write: false }, + }) + } finally { + measuring = false + } + }, + + resolveId(id) { + return isScaffoldId(id) ? id : null + }, + load(id) { + if (!isScaffoldId(id)) return null + const { moduleId, siteIndex } = decodeScaffoldId(id) + const keys = scaffoldKeys.get(moduleId)?.get(siteIndex) ?? [] + return scaffoldSource(keys) + }, + + async transform(code, id) { + const file = normalize(id) + const { sites } = analyzeModule(code, file) + if (sites.length === 0) return null + + if (measuring) { + // Pass 1: build scaffolds (need namespace key universes), rewrite to namespace imports. + const perSite = new Map() + for (const site of sites) { + const nsKeys = new Map() + for (const s of site.sources) { + if (s.kind === "namespace") nsKeys.set(s.moduleId, await enumerateNamespaceKeys(s.moduleId, root)) + } + perSite.set(site.siteIndex, [...keyUniverse(site.sources, nsKeys)]) + } + scaffoldKeys.set(file, perSite) + const result = rewriteMeasure(code, sites, site => encodeScaffoldId(file, site.siteIndex)) + return { code: result.code, map: result.map } + } + + // Pass 2 (real build): narrow using the measured used-keys. + const used = measured.get(file) + if (!used) return null + const nsKeys = new Map() + for (const site of sites) + for (const s of site.sources) + if (s.kind === "namespace" && !nsKeys.has(s.moduleId)) + nsKeys.set(s.moduleId, await enumerateNamespaceKeys(s.moduleId, root)) + const result = rewriteEmit(code, sites, site => used.get(site.siteIndex) ?? new Set(), nsKeys) + return { code: result.code, map: result.map } + }, + + generateBundle(_opts, bundle) { + if (!measuring) return // only the measure build records usage + for (const chunk of Object.values(bundle)) { + if (chunk.type !== "chunk") continue + for (const [mid, mod] of Object.entries(chunk.modules)) { + if (!isScaffoldId(mid)) continue + const { moduleId, siteIndex } = decodeScaffoldId(mid) + const perSite = measured.get(moduleId) ?? new Map>() + perSite.set(siteIndex, new Set(mod.renderedExports)) + measured.set(moduleId, perSite) + } + } + }, + } +} +``` + +> Orchestration notes baked in: `measuring` guards recursion; `configFile: false` stops the sub-build re-reading the user's vite config file (we replay the captured `userConfig`, which already includes this plugin instance — the guard makes the nested instance behave as the measure pass). `measured`/`scaffoldKeys` are module-level so they survive across the two build instances in one process. +> +> **Fallback if instance-reuse misbehaves (the spike used two instances, not one):** the spike validated orchestration with a *separate* measure plugin instance. If replaying `userConfig` with the same plugin instance causes hook-state issues, switch to two plugins sharing the module-level `measured`/`scaffoldKeys`: the main plugin's `buildStart` builds with `plugins: [measurePlugin(root), ...userConfig.plugins.filter(p => p?.name !== "vite-plugin-solid-three")]` (drop ourselves, inject a fresh measure plugin that owns `resolveId`/`load`/pass-1 `transform`/`generateBundle`); the main plugin then only does config capture, `buildStart` orchestration, and pass-2 `transform`. No recursion guard needed because the measure build doesn't contain the main plugin. Decide empirically at Step 4 — both share all the pure modules (analyze/rewrite/scaffold/providers), so the choice is localized to `index.ts`. + +- [ ] **Step 2: namespace fixture + harness** + +`test/fixtures/_harness.ts`: + +```ts +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { build } from "vite" +import solid from "vite-plugin-solid" +import solidThree from "../../src/index.ts" + +const here = dirname(fileURLToPath(import.meta.url)) + +export async function buildFixture(name: string, entry = "scene.tsx"): Promise { + const root = resolve(here, name) + const output = await build({ + root, + logLevel: "silent", + plugins: [solidThree(), solid()], + build: { write: false, minify: false, lib: { entry: resolve(root, entry), formats: ["es"], fileName: "out" } }, + }) + const chunks = (Array.isArray(output) ? output : [output]).flatMap(o => ("output" in o ? o.output : [])) + return chunks.filter(c => c.type === "chunk").map(c => (c as { code: string }).code).join("\n") +} +``` + +`test/fixtures/namespace/catalog.ts`: + +```ts +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) +``` + +`test/fixtures/namespace/scene.tsx`: + +```tsx +import { T } from "./catalog.ts" +export function Scene() { + return +} +``` + +`test/fixtures/namespace/tsconfig.json`: + +```json +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } +``` + +- [ ] **Step 3: failing test** + +`test/fixtures/fixtures.test.ts`: + +```ts +import { describe, expect, it } from "vitest" +import { buildFixture } from "./_harness.ts" + +describe("fixture matrix", () => { + it("namespace: narrows createT(THREE) to the used class", async () => { + const code = await buildFixture("namespace") + expect(code).toContain("Mesh") + expect(code).not.toContain("BoxGeometry") + }) +}) +``` + +- [ ] **Step 4: run — iterate to green** + +Run: `pnpm vitest run test/fixtures/fixtures.test.ts -t "namespace"` +Expected: PASS — the measure sub-build records `Mesh`, the real build emits `createT({ Mesh: THREE.Mesh })`, `BoxGeometry` is dropped. + +> This is the integration linchpin. If it hangs or recurses, check the `measuring` guard and `configFile: false`. If `BoxGeometry` survives, confirm `generateBundle` is reading scaffold `renderedExports` (log `measured`). + +- [ ] **Step 5: typecheck + commit** + +```bash +git add packages/vite-plugin-solid-three/src/index.ts packages/vite-plugin-solid-three/test/fixtures +git commit -m "feat(vps3): Vite plugin with two-pass measure/emit orchestration" +``` + +--- + +## Task 11: Fixture matrix — custom literal, override, dynamic-bail, union + +**Files:** +- Create fixtures under `test/fixtures/{literal,override,dynamic,union}/` +- Modify: `test/fixtures/fixtures.test.ts` + +Mirror the spikes. Each catalog/scene is tiny; assertions check drop/keep. + +- [ ] **Step 1: literal** — `catalog.ts`: `export const T = createT({ Used: Box, Unused: Sphere })` (import `Box`/`Sphere` as custom classes from a local `classes.ts` that re-exports two distinct three classes); scene uses `T.Used`. Assert the `Used` class survives, `Unused` dropped. + +- [ ] **Step 2: override** — `catalog.ts`: `export const T = createT({ ...THREE, Mesh: CustomMesh })`; scene uses `T.Mesh` + `T.Group`. Assert `CustomMesh` present, `THREE.Group` present, `THREE.Mesh` absent (overridden), other three classes absent. + +- [ ] **Step 3: dynamic (bail)** — `scene.tsx` accesses `T[(globalThis as any).k]`. Assert the catalogue is NOT narrowed (a second unused class still present) and the build still succeeds. + +- [ ] **Step 4: union** — two scenes importing the same `T`, one uses `T.Mesh`, the other `T.Group`; an aggregate entry imports both. Assert both `Mesh` and `Group` survive, a third unused class dropped. + +- [ ] **Step 5: tests** + +```ts +describe("fixture matrix — shapes", () => { + it("literal: drops the unused custom class", async () => { + const code = await buildFixture("literal") + expect(code).toContain("USED_MARK"); expect(code).not.toContain("UNUSED_MARK") + }) + it("override: last-write-wins drops the overridden THREE.Mesh", async () => { + const code = await buildFixture("override") + expect(code).toContain("CUSTOM_MESH"); expect(code).toContain("Group") + expect(code).not.toContain("BoxGeometry") + }) + it("dynamic: bails to full catalogue, build still works", async () => { + const code = await buildFixture("dynamic") + expect(code).toContain("BoxGeometry") // not narrowed + }) + it("union: keeps the union across files", async () => { + const code = await buildFixture("union", "entry.tsx") + expect(code).toContain("Mesh"); expect(code).toContain("Group") + expect(code).not.toContain("TorusKnotGeometry") + }) +}) +``` + +- [ ] **Step 6: run — green; commit** + +```bash +git add packages/vite-plugin-solid-three/test/fixtures +git commit -m "test(vps3): fixture matrix — literal, override, dynamic-bail, union" +``` + +--- + +## Task 12: Code-splitting / dynamic-import fixtures + +**Files:** +- Create fixtures under `test/fixtures/{code-split,dynamic-import}/` +- Modify: `test/fixtures/fixtures.test.ts` + +Proves the bundler-measurement handles split graphs natively (no special handling). + +- [ ] **Step 1: code-split** — `lazy(() => import("./Heavy.tsx"))` where `Heavy` uses a class no eager module does. Assert that class survives. +- [ ] **Step 2: dynamic-import** — a scene loaded via `import(\`./scenes/${n}.tsx\`)` using a marker class. Assert it survives. +- [ ] **Step 3: tests + run + commit** + +```ts +it("code-split: a class only in a lazy chunk survives", async () => { + expect(await buildFixture("code-split")).toContain("DodecahedronGeometry") +}) +it("dynamic-import: a class only in a dynamically-imported scene survives", async () => { + expect(await buildFixture("dynamic-import")).toContain("TorusKnotGeometry") +}) +``` + +```bash +git add packages/vite-plugin-solid-three/test/fixtures +git commit -m "test(vps3): code-split and dynamic-import fixtures" +``` + +--- + +## Task 13: Browser soundness oracle (release gate) + +**Files:** +- Create: `packages/vite-plugin-solid-three/vitest.browser.config.ts` +- Create: `packages/vite-plugin-solid-three/test/oracle/{shim.ts,scene.fixture.tsx,oracle.test.tsx}` + +Instrument the runtime `createT` proxy to record every key requested at render; assert that set ⊆ the narrowed catalogue produced for the same fixture. (Port the v1 oracle shape: mount `` from the same mocked `solid-three`; key recorded keys by name; swallow the incidental `boundingSphere` frameloop error.) + +- [ ] **Step 1: browser vitest config** (Playwright + SwiftShader; `optimizeDeps.include: ["@solidjs/testing-library", "three"]`; `globalSetup` builds the namespace fixture through the plugin and writes the narrowed key set to a JSON the test reads). +- [ ] **Step 2: recording shim** wrapping the real `createT` (via `importOriginal`) to populate a `requestedKeys` set. +- [ ] **Step 3: oracle test** — render the fixture scene, collect `requestedKeys ∩ three`, assert `leaked = requested − narrowed` is empty, and that the render was non-vacuous. +- [ ] **Step 4: run + commit** + +Run: `pnpm test:oracle` +Expected: PASS — no class used at runtime is missing from the narrowed catalogue. + +```bash +git add packages/vite-plugin-solid-three/vitest.browser.config.ts packages/vite-plugin-solid-three/test/oracle +git commit -m "test(vps3): browser soundness oracle (release gate)" +``` + +--- + +## Task 14: README + final gate + +**Files:** +- Create: `packages/vite-plugin-solid-three/README.md` +- Modify: `pnpm-workspace.yaml` only if needed (already globs `packages/*`). + +- [ ] **Step 1: README** — install, Vite usage (`plugins: [solidThree(), solid()]`), how it works (two-pass, build-only, dev untouched), and the bail behavior (dynamic catalogues stay full + dynamic; usage `T[expr]` deopts to full). Keep it short. + +- [ ] **Step 2: full gate** + +Run: `pnpm tsc --noEmit -p tsconfig.json && pnpm vitest run && pnpm test:oracle && pnpm build` +Expected: all green. + +- [ ] **Step 3: commit** + +```bash +git add packages/vite-plugin-solid-three/README.md +git commit -m "docs(vps3): README" +``` + +--- + +## Self-review checklist (run after implementation) + +- **Spec coverage:** single-file analysis (T2–5) · namespace/literal/spread/override/getter providers (T3,4,8) · bail scope (T5) · scaffold + measurement (T6,10) · key enumeration (T7) · two-pass orchestration (T10) · cross-file union & code-split via the bundler (T11,12) · oracle gate (T13). +- **Soundness direction:** every fixture asserts either "unused class dropped + used class kept" or "bail keeps full." No fixture asserts a used class is dropped. +- **Type consistency:** `CatalogueSource`/`CatalogueSite` defined once in `types.ts`; `analyzeModule` → `rewriteMeasure`/`rewriteEmit` consume the same offsets; `measured`/`scaffoldKeys` keys are `(normalized moduleId, siteIndex)` everywhere. +- **Orchestration:** confirm exactly one nested build per real build (no recursion), and that `measured` is populated before pass-2 transform runs (buildStart awaits the sub-build). + +## Open items / deferred + +- **2× build cost:** acceptable for v1; later optimize the measure build (drop non-essential plugins, skip minify — but keep the same module graph). Log if pursued. +- **Multiple `createT` calls per module / multiple catalogues:** handled by `siteIndex`; add a fixture if it proves common. +- **`three/webgpu` and other namespaces:** covered if node-resolvable for key enumeration; add a fixture when prioritized. +- **Config replay edge cases:** SSR/multiple environments, `build.rollupOptions.input` arrays — validate against a SolidStart-style config before release. + +## Execution handoff + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — fresh subagent per task, two-stage review between tasks. +2. **Inline Execution** — batch with checkpoints. + +Task 10 (orchestration) is the highest-risk; review it carefully whichever path is chosen. From c29180100bddd8daee2f0d70bd85da0aa34693f4 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:47:47 +0200 Subject: [PATCH 03/21] chore(vps3): scaffold vite-plugin-solid-three package --- packages/vite-plugin-solid-three/package.json | 32 +++++++++++++++++++ packages/vite-plugin-solid-three/src/index.ts | 1 + .../vite-plugin-solid-three/tsconfig.json | 13 ++++++++ .../vite-plugin-solid-three/vitest.config.ts | 12 +++++++ 4 files changed, 58 insertions(+) create mode 100644 packages/vite-plugin-solid-three/package.json create mode 100644 packages/vite-plugin-solid-three/src/index.ts create mode 100644 packages/vite-plugin-solid-three/tsconfig.json create mode 100644 packages/vite-plugin-solid-three/vitest.config.ts diff --git a/packages/vite-plugin-solid-three/package.json b/packages/vite-plugin-solid-three/package.json new file mode 100644 index 00000000..4261018e --- /dev/null +++ b/packages/vite-plugin-solid-three/package.json @@ -0,0 +1,32 @@ +{ + "name": "vite-plugin-solid-three", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }, + "files": ["dist/**"], + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "test": "vitest run", + "test:oracle": "vitest run --config vitest.browser.config.ts", + "test:ts": "tsc --noEmit -p tsconfig.json && vitest run" + }, + "dependencies": { "magic-string": "^0.30.0" }, + "peerDependencies": { "typescript": ">=5.0.0", "vite": "^5 || ^6" }, + "devDependencies": { + "@solidjs/testing-library": "^0.8.8", + "@vitest/browser": "^4.1.7", + "@vitest/browser-playwright": "^4.1.7", + "playwright": "^1.60.0", + "solid-js": "^1.8.17", + "solid-three": "workspace:*", + "three": "^0.181.2", + "tsup": "^8.0.2", + "typescript": "^5.4.5", + "vite": "6.4.2", + "vite-plugin-solid": "2.11.12", + "vitest": "^4.1.7" + } +} diff --git a/packages/vite-plugin-solid-three/src/index.ts b/packages/vite-plugin-solid-three/src/index.ts new file mode 100644 index 00000000..7f814ebd --- /dev/null +++ b/packages/vite-plugin-solid-three/src/index.ts @@ -0,0 +1 @@ +export const placeholder = true diff --git a/packages/vite-plugin-solid-three/tsconfig.json b/packages/vite-plugin-solid-three/tsconfig.json new file mode 100644 index 00000000..dbbb17e2 --- /dev/null +++ b/packages/vite-plugin-solid-three/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/vite-plugin-solid-three/vitest.config.ts b/packages/vite-plugin-solid-three/vitest.config.ts new file mode 100644 index 00000000..6b183860 --- /dev/null +++ b/packages/vite-plugin-solid-three/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + exclude: ["test/oracle/**"], + environment: "node", + // real vite builds in fixtures are heavy; serialize and give headroom. + fileParallelism: false, + testTimeout: 60_000, + }, +}) From ebed63f4a2f1514db2b60d00984a96b42d983e75 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:50:21 +0200 Subject: [PATCH 04/21] feat(vps3): recognize createT calls and result binding (single-file) --- .../vite-plugin-solid-three/src/analyze.ts | 71 +++++++++++++++++++ packages/vite-plugin-solid-three/src/types.ts | 26 +++++++ .../test/analyze.test.ts | 31 ++++++++ 3 files changed, 128 insertions(+) create mode 100644 packages/vite-plugin-solid-three/src/analyze.ts create mode 100644 packages/vite-plugin-solid-three/src/types.ts create mode 100644 packages/vite-plugin-solid-three/test/analyze.test.ts diff --git a/packages/vite-plugin-solid-three/src/analyze.ts b/packages/vite-plugin-solid-three/src/analyze.ts new file mode 100644 index 00000000..c10180cb --- /dev/null +++ b/packages/vite-plugin-solid-three/src/analyze.ts @@ -0,0 +1,71 @@ +import ts from "typescript" +import type { CatalogueSite, ModuleAnalysis } from "./types.ts" + +const SOLID_THREE = "solid-three" +const FACTORY = "createT" + +/** Local names that `createT` is bound to via `import { createT [as x] } from "solid-three"`. */ +function factoryAliases(sf: ts.SourceFile): Set { + const names = new Set() + sf.forEachChild(node => { + if (!ts.isImportDeclaration(node)) return + if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== SOLID_THREE) return + const named = node.importClause?.namedBindings + if (named && ts.isNamedImports(named)) { + for (const el of named.elements) { + if ((el.propertyName?.text ?? el.name.text) === FACTORY) names.add(el.name.text) + } + } + }) + return names +} + +export function analyzeModule(code: string, fileName: string): ModuleAnalysis { + const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + const aliases = factoryAliases(sf) + const sites: CatalogueSite[] = [] + if (aliases.size === 0) return { sites, bails: [] } + + let siteIndex = 0 + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + aliases.has(node.expression.text) + ) { + const site = siteFromCall(node, sf, siteIndex) + if (site) { + sites.push(site) + siteIndex++ + } + } + ts.forEachChild(node, visit) + } + visit(sf) + return { sites, bails: [] } +} + +/** Build a partial site from a createT call: binding + statement offsets. Arg sources filled in later tasks. */ +function siteFromCall(call: ts.CallExpression, sf: ts.SourceFile, siteIndex: number): CatalogueSite | undefined { + const arg = call.arguments[0] + if (!arg) return undefined + + // result binding: `const T = createT(...)` (optionally exported) + const varDecl = call.parent + if (!ts.isVariableDeclaration(varDecl) || !ts.isIdentifier(varDecl.name)) return undefined + const varList = varDecl.parent + const statement = varList.parent + if (!ts.isVariableStatement(statement)) return undefined + const exported = !!statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) + + return { + binding: varDecl.name.text, + exported, + sources: [], // filled in later tasks + argStart: arg.getStart(sf), + argEnd: arg.getEnd(), + statementStart: statement.getStart(sf), + statementEnd: statement.getEnd(), + siteIndex, + } +} diff --git a/packages/vite-plugin-solid-three/src/types.ts b/packages/vite-plugin-solid-three/src/types.ts new file mode 100644 index 00000000..3aa33694 --- /dev/null +++ b/packages/vite-plugin-solid-three/src/types.ts @@ -0,0 +1,26 @@ +// A catalogue is an ordered list of sources (last writer wins on key collisions). +export type CatalogueSource = + | { kind: "namespace"; localName: string; moduleId: string } // createT(THREE) or {...THREE} + | { kind: "entry"; key: string; valueText: string } // { Mesh: X } or { get Foo(){…} } (verbatim) + +export interface CatalogueSite { + binding: string // result binding name, e.g. "T" + exported: boolean // was the binding statement `export`ed + sources: CatalogueSource[] // empty is impossible for a non-bail site + // char offsets into the module source: + argStart: number // start of the createT argument + argEnd: number // end of the createT argument + statementStart: number // start of `const T = createT(...)` (or `export const ...`) + statementEnd: number // end of that statement + siteIndex: number // 0-based index of this createT site within the module +} + +export interface BailSite { + siteIndex: number + reason: string +} + +export interface ModuleAnalysis { + sites: CatalogueSite[] + bails: BailSite[] +} diff --git a/packages/vite-plugin-solid-three/test/analyze.test.ts b/packages/vite-plugin-solid-three/test/analyze.test.ts new file mode 100644 index 00000000..1eef0033 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/analyze.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { analyzeModule } from "../src/analyze.ts" + +describe("analyzeModule — call recognition", () => { + it("finds createT, the binding, and export flag", () => { + const code = `import { createT } from "solid-three" +import * as THREE from "three" +export const T = createT(THREE)` + const { sites } = analyzeModule(code, "/catalog.ts") + expect(sites).toHaveLength(1) + expect(sites[0].binding).toBe("T") + expect(sites[0].exported).toBe(true) + expect(code.slice(sites[0].argStart, sites[0].argEnd)).toBe("THREE") + }) + + it("follows a renamed createT import", () => { + const code = `import { createT as mk } from "solid-three" +import * as THREE from "three" +const T = mk(THREE)` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites).toHaveLength(1) + expect(sites[0].binding).toBe("T") + expect(sites[0].exported).toBe(false) + }) + + it("ignores createT-looking calls not imported from solid-three", () => { + const code = `function createT(x){return x} +const T = createT({})` + expect(analyzeModule(code, "/c.ts").sites).toHaveLength(0) + }) +}) From 57cb163a27572d32de517e25d3b44633b4662771 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:53:44 +0200 Subject: [PATCH 05/21] feat(vps3): classify namespace catalogue argument --- .../vite-plugin-solid-three/src/analyze.ts | 65 ++++++++++++++++--- .../test/analyze.test.ts | 18 +++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/vite-plugin-solid-three/src/analyze.ts b/packages/vite-plugin-solid-three/src/analyze.ts index c10180cb..324913dd 100644 --- a/packages/vite-plugin-solid-three/src/analyze.ts +++ b/packages/vite-plugin-solid-three/src/analyze.ts @@ -1,5 +1,5 @@ import ts from "typescript" -import type { CatalogueSite, ModuleAnalysis } from "./types.ts" +import type { BailSite, CatalogueSite, CatalogueSource, ModuleAnalysis } from "./types.ts" const SOLID_THREE = "solid-three" const FACTORY = "createT" @@ -20,12 +20,25 @@ function factoryAliases(sf: ts.SourceFile): Set { return names } +/** local namespace name -> module specifier, from `import * as NS from "mod"`. */ +function namespaceImports(sf: ts.SourceFile): Map { + const map = new Map() + sf.forEachChild(node => { + if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return + const named = node.importClause?.namedBindings + if (named && ts.isNamespaceImport(named)) map.set(named.name.text, node.moduleSpecifier.text) + }) + return map +} + export function analyzeModule(code: string, fileName: string): ModuleAnalysis { const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) const aliases = factoryAliases(sf) const sites: CatalogueSite[] = [] - if (aliases.size === 0) return { sites, bails: [] } + const bails: BailSite[] = [] + if (aliases.size === 0) return { sites, bails } + const namespaces = namespaceImports(sf) let siteIndex = 0 const visit = (node: ts.Node) => { if ( @@ -33,20 +46,26 @@ export function analyzeModule(code: string, fileName: string): ModuleAnalysis { ts.isIdentifier(node.expression) && aliases.has(node.expression.text) ) { - const site = siteFromCall(node, sf, siteIndex) - if (site) { - sites.push(site) + const result = siteFromCall(node, sf, namespaces, siteIndex) + if (result !== undefined) { + if ("bail" in result) bails.push({ siteIndex, reason: result.bail }) + else sites.push(result) siteIndex++ } } ts.forEachChild(node, visit) } visit(sf) - return { sites, bails: [] } + return { sites, bails } } -/** Build a partial site from a createT call: binding + statement offsets. Arg sources filled in later tasks. */ -function siteFromCall(call: ts.CallExpression, sf: ts.SourceFile, siteIndex: number): CatalogueSite | undefined { +/** Returns a full site, a bail (recognized createT but unanalyzable arg), or undefined (no trackable binding). */ +function siteFromCall( + call: ts.CallExpression, + sf: ts.SourceFile, + namespaces: Map, + siteIndex: number, +): CatalogueSite | { bail: string } | undefined { const arg = call.arguments[0] if (!arg) return undefined @@ -58,10 +77,13 @@ function siteFromCall(call: ts.CallExpression, sf: ts.SourceFile, siteIndex: num if (!ts.isVariableStatement(statement)) return undefined const exported = !!statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) + const classified = classifyArg(arg, namespaces, sf) + if ("bail" in classified) return { bail: classified.bail } + return { binding: varDecl.name.text, exported, - sources: [], // filled in later tasks + sources: classified.sources, argStart: arg.getStart(sf), argEnd: arg.getEnd(), statementStart: statement.getStart(sf), @@ -69,3 +91,28 @@ function siteFromCall(call: ts.CallExpression, sf: ts.SourceFile, siteIndex: num siteIndex, } } + +function classifyArg( + arg: ts.Expression, + namespaces: Map, + sf: ts.SourceFile, +): { sources: CatalogueSource[] } | { bail: string } { + // createT(THREE) + if (ts.isIdentifier(arg)) { + const moduleId = namespaces.get(arg.text) + if (moduleId) return { sources: [{ kind: "namespace", localName: arg.text, moduleId }] } + return { bail: `argument ${arg.text} is not a resolvable namespace import` } + } + // object literals: implemented in a later task + if (ts.isObjectLiteralExpression(arg)) return classifyObject(arg, namespaces, sf) + return { bail: `unsupported catalogue argument (${ts.SyntaxKind[arg.kind]})` } +} + +/** Stub — replaced in Task 4. */ +function classifyObject( + _obj: ts.ObjectLiteralExpression, + _namespaces: Map, + _sf: ts.SourceFile, +): { sources: CatalogueSource[] } | { bail: string } { + return { bail: "object literals — Task 4" } +} diff --git a/packages/vite-plugin-solid-three/test/analyze.test.ts b/packages/vite-plugin-solid-three/test/analyze.test.ts index 1eef0033..f2fd7f2b 100644 --- a/packages/vite-plugin-solid-three/test/analyze.test.ts +++ b/packages/vite-plugin-solid-three/test/analyze.test.ts @@ -29,3 +29,21 @@ const T = createT({})` expect(analyzeModule(code, "/c.ts").sites).toHaveLength(0) }) }) + +describe("analyzeModule — namespace arg", () => { + it("records createT(THREE) as a namespace source pointing at the module", () => { + const code = `import { createT } from "solid-three" +import * as THREE from "three" +export const T = createT(THREE)` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites[0].sources).toEqual([{ kind: "namespace", localName: "THREE", moduleId: "three" }]) + }) + + it("bails when the arg is a namespace alias it cannot resolve to an import", () => { + const code = `import { createT } from "solid-three" +const T = createT(SOMETHING)` + const { sites, bails } = analyzeModule(code, "/c.ts") + expect(sites).toHaveLength(0) + expect(bails[0].reason).toMatch(/not.*namespace|unresolved/i) + }) +}) From 11e0dbfb72f6bc65eb4c79acfab28ed4858275c6 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:55:42 +0200 Subject: [PATCH 06/21] feat(vps3): classify object-literal catalogue (entries, spreads, getters) --- .../vite-plugin-solid-three/src/analyze.ts | 40 ++++++++++++++++--- .../test/analyze.test.ts | 31 ++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/vite-plugin-solid-three/src/analyze.ts b/packages/vite-plugin-solid-three/src/analyze.ts index 324913dd..3afb4cde 100644 --- a/packages/vite-plugin-solid-three/src/analyze.ts +++ b/packages/vite-plugin-solid-three/src/analyze.ts @@ -108,11 +108,41 @@ function classifyArg( return { bail: `unsupported catalogue argument (${ts.SyntaxKind[arg.kind]})` } } -/** Stub — replaced in Task 4. */ function classifyObject( - _obj: ts.ObjectLiteralExpression, - _namespaces: Map, - _sf: ts.SourceFile, + obj: ts.ObjectLiteralExpression, + namespaces: Map, + sf: ts.SourceFile, ): { sources: CatalogueSource[] } | { bail: string } { - return { bail: "object literals — Task 4" } + const sources: CatalogueSource[] = [] + for (const prop of obj.properties) { + if (ts.isSpreadAssignment(prop)) { + if (!ts.isIdentifier(prop.expression)) return { bail: "spread of a non-identifier" } + const moduleId = namespaces.get(prop.expression.text) + if (!moduleId) return { bail: `spread of non-namespace ${prop.expression.text}` } + sources.push({ kind: "namespace", localName: prop.expression.text, moduleId }) + continue + } + // computed keys are not statically enumerable + const nameNode = + ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop) || + ts.isGetAccessorDeclaration(prop) || ts.isMethodDeclaration(prop) + ? prop.name + : undefined + if (!nameNode) return { bail: "unsupported property" } + if (ts.isComputedPropertyName(nameNode)) return { bail: "computed property key" } + const key = ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) ? nameNode.text : undefined + if (key === undefined) return { bail: "non-static property key" } + + if (ts.isPropertyAssignment(prop)) { + sources.push({ kind: "entry", key, valueText: prop.initializer.getText(sf) }) + } else if (ts.isShorthandPropertyAssignment(prop)) { + sources.push({ kind: "entry", key, valueText: key }) + } else if (ts.isGetAccessorDeclaration(prop) || ts.isMethodDeclaration(prop)) { + sources.push({ kind: "entry", key, valueText: prop.getText(sf) }) // verbatim getter/method + } else { + return { bail: "unsupported property" } + } + } + if (sources.length === 0) return { bail: "empty catalogue" } + return { sources } } diff --git a/packages/vite-plugin-solid-three/test/analyze.test.ts b/packages/vite-plugin-solid-three/test/analyze.test.ts index f2fd7f2b..d31b296f 100644 --- a/packages/vite-plugin-solid-three/test/analyze.test.ts +++ b/packages/vite-plugin-solid-three/test/analyze.test.ts @@ -47,3 +47,34 @@ const T = createT(SOMETHING)` expect(bails[0].reason).toMatch(/not.*namespace|unresolved/i) }) }) + +describe("analyzeModule — object arg", () => { + it("reads explicit entries with verbatim value text", () => { + const code = `import { createT } from "solid-three" +import { MyMesh } from "./x" +export const T = createT({ Mesh: MyMesh })` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites[0].sources).toEqual([{ kind: "entry", key: "Mesh", valueText: "MyMesh" }]) + }) + + it("reads a namespace spread then an overriding entry, in order", () => { + const code = `import { createT } from "solid-three" +import * as THREE from "three" +import { Custom } from "./x" +export const T = createT({ ...THREE, Mesh: Custom })` + const { sites } = analyzeModule(code, "/c.ts") + expect(sites[0].sources).toEqual([ + { kind: "namespace", localName: "THREE", moduleId: "three" }, + { kind: "entry", key: "Mesh", valueText: "Custom" }, + ]) + }) + + it("keeps a getter verbatim", () => { + const code = `import { createT } from "solid-three" +export const T = createT({ get Foo(){ return Date.now() > 0 ? A : B } })` + const { sites } = analyzeModule(code, "/c.ts") + const src = sites[0].sources[0] + expect(src).toMatchObject({ kind: "entry", key: "Foo" }) + expect((src as any).valueText).toContain("get Foo()") + }) +}) From 923f0613c29efea1f0e3e1dc78ef8d137554cc8d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:56:48 +0200 Subject: [PATCH 07/21] test(vps3): lock catalogue bail cases --- .../vite-plugin-solid-three/test/analyze.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/vite-plugin-solid-three/test/analyze.test.ts b/packages/vite-plugin-solid-three/test/analyze.test.ts index d31b296f..fedfbc9b 100644 --- a/packages/vite-plugin-solid-three/test/analyze.test.ts +++ b/packages/vite-plugin-solid-three/test/analyze.test.ts @@ -78,3 +78,17 @@ export const T = createT({ get Foo(){ return Date.now() > 0 ? A : B } })` expect((src as any).valueText).toContain("get Foo()") }) }) + +describe("analyzeModule — bails", () => { + it.each([ + [`const T = createT(store)`, /not a resolvable namespace/i], + [`const T = createT({ [k]: X })`, /computed property/i], + [`const T = createT({ ...runtimeObj })`, /non-namespace/i], + [`const T = createT(makeIt())`, /unsupported catalogue argument/i], + ])("bails on %s", (body, reason) => { + const code = `import { createT } from "solid-three"\n${body}` + const { sites, bails } = analyzeModule(code, "/c.ts") + expect(sites).toHaveLength(0) + expect(bails[0].reason).toMatch(reason) + }) +}) From 4e63f90124eecb371a708bb312ebed0267c763b1 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 16:58:20 +0200 Subject: [PATCH 08/21] feat(vps3): scaffold module generator and id codec --- .../vite-plugin-solid-three/src/scaffold.ts | 19 +++++++++++++++++++ .../test/scaffold.test.ts | 14 ++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 packages/vite-plugin-solid-three/src/scaffold.ts create mode 100644 packages/vite-plugin-solid-three/test/scaffold.test.ts diff --git a/packages/vite-plugin-solid-three/src/scaffold.ts b/packages/vite-plugin-solid-three/src/scaffold.ts new file mode 100644 index 00000000..5eb9e0df --- /dev/null +++ b/packages/vite-plugin-solid-three/src/scaffold.ts @@ -0,0 +1,19 @@ +const PREFIX = "\0vps3-scaffold:" + +export function scaffoldSource(keys: string[]): string { + return keys.map(k => `export const ${k} = 0\n`).join("") +} + +export function encodeScaffoldId(moduleId: string, siteIndex: number): string { + return `${PREFIX}${siteIndex}:${encodeURIComponent(moduleId)}` +} + +export function isScaffoldId(id: string): boolean { + return id.startsWith(PREFIX) +} + +export function decodeScaffoldId(id: string): { moduleId: string; siteIndex: number } { + const rest = id.slice(PREFIX.length) + const colon = rest.indexOf(":") + return { siteIndex: Number(rest.slice(0, colon)), moduleId: decodeURIComponent(rest.slice(colon + 1)) } +} diff --git a/packages/vite-plugin-solid-three/test/scaffold.test.ts b/packages/vite-plugin-solid-three/test/scaffold.test.ts new file mode 100644 index 00000000..612bef7c --- /dev/null +++ b/packages/vite-plugin-solid-three/test/scaffold.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest" +import { scaffoldSource, encodeScaffoldId, decodeScaffoldId, isScaffoldId } from "../src/scaffold.ts" + +describe("scaffold", () => { + it("emits one droppable named export per key", () => { + expect(scaffoldSource(["Mesh", "Group"])).toBe("export const Mesh = 0\nexport const Group = 0\n") + }) + + it("round-trips an id (module + site index)", () => { + const id = encodeScaffoldId("/abs/catalog.ts", 2) + expect(isScaffoldId(id)).toBe(true) + expect(decodeScaffoldId(id)).toEqual({ moduleId: "/abs/catalog.ts", siteIndex: 2 }) + }) +}) From da258ad6829e23652a3b0d08f37ef1b2775629bf Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:00:17 +0200 Subject: [PATCH 09/21] feat(vps3): enumerate namespace module export names --- .../src/namespace-keys.ts | 29 +++++++++++++++++++ .../test/namespace-keys.test.ts | 11 +++++++ 2 files changed, 40 insertions(+) create mode 100644 packages/vite-plugin-solid-three/src/namespace-keys.ts create mode 100644 packages/vite-plugin-solid-three/test/namespace-keys.test.ts diff --git a/packages/vite-plugin-solid-three/src/namespace-keys.ts b/packages/vite-plugin-solid-three/src/namespace-keys.ts new file mode 100644 index 00000000..003ec997 --- /dev/null +++ b/packages/vite-plugin-solid-three/src/namespace-keys.ts @@ -0,0 +1,29 @@ +import { createRequire } from "node:module" +import { pathToFileURL } from "node:url" + +const cache = new Map>() + +/** Export names of a module, resolved from `root`, excluding `default` and internals. */ +export function enumerateNamespaceKeys(moduleId: string, root: string): Promise { + const cacheKey = `${root}\0${moduleId}` + let pending = cache.get(cacheKey) + if (!pending) { + pending = load(moduleId, root) + cache.set(cacheKey, pending) + } + return pending +} + +async function load(moduleId: string, root: string): Promise { + const require = createRequire(pathToFileURL(`${root}/package.json`)) + const resolved = require.resolve(moduleId) + const ns = await import(pathToFileURL(resolved).href) + let keys = Object.keys(ns).filter(k => k !== "default" && !k.startsWith("__")) + // CJS modules imported via ESM interop often surface only `{ default }`. + // When that happens, fall back to the default export if it is a plain object + // holding the actual namespace members (e.g. three's CJS build). + if (keys.length === 0 && ns.default != null && typeof ns.default === "object") { + keys = Object.keys(ns.default).filter(k => k !== "default" && !k.startsWith("__")) + } + return keys +} diff --git a/packages/vite-plugin-solid-three/test/namespace-keys.test.ts b/packages/vite-plugin-solid-three/test/namespace-keys.test.ts new file mode 100644 index 00000000..37840a13 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/namespace-keys.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest" +import { enumerateNamespaceKeys } from "../src/namespace-keys.ts" + +describe("enumerateNamespaceKeys", () => { + it("returns three's class names including Mesh and BoxGeometry", async () => { + const keys = await enumerateNamespaceKeys("three", process.cwd()) + expect(keys).toContain("Mesh") + expect(keys).toContain("BoxGeometry") + expect(keys).not.toContain("default") + }) +}) From 5f1e4e94de9e7598e6c51e7e965c46e08be205eb Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:01:34 +0200 Subject: [PATCH 10/21] feat(vps3): key-universe and last-write-wins provider resolution --- .../vite-plugin-solid-three/src/providers.ts | 22 +++++++++++++++++++ .../test/providers.test.ts | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 packages/vite-plugin-solid-three/src/providers.ts create mode 100644 packages/vite-plugin-solid-three/test/providers.test.ts diff --git a/packages/vite-plugin-solid-three/src/providers.ts b/packages/vite-plugin-solid-three/src/providers.ts new file mode 100644 index 00000000..d3faf2e5 --- /dev/null +++ b/packages/vite-plugin-solid-three/src/providers.ts @@ -0,0 +1,22 @@ +import type { CatalogueSource } from "./types.ts" + +type NsKeys = Map // moduleId -> export names + +export function keyUniverse(sources: CatalogueSource[], nsKeys: NsKeys): Set { + const keys = new Set() + for (const s of sources) { + if (s.kind === "namespace") for (const k of nsKeys.get(s.moduleId) ?? []) keys.add(k) + else keys.add(s.key) + } + return keys +} + +/** The provider expression for `key`, applying last-writer-wins over sources. */ +export function providerFor(key: string, sources: CatalogueSource[], nsKeys: NsKeys): string | undefined { + for (let i = sources.length - 1; i >= 0; i--) { + const s = sources[i] + if (s.kind === "entry" && s.key === key) return s.valueText + if (s.kind === "namespace" && (nsKeys.get(s.moduleId) ?? []).includes(key)) return `${s.localName}.${key}` + } + return undefined +} diff --git a/packages/vite-plugin-solid-three/test/providers.test.ts b/packages/vite-plugin-solid-three/test/providers.test.ts new file mode 100644 index 00000000..7841ca74 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/providers.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" +import { keyUniverse, providerFor } from "../src/providers.ts" +import type { CatalogueSource } from "../src/types.ts" + +const nsKeys = new Map([["three", ["Mesh", "Group", "Box"]]]) +const sources: CatalogueSource[] = [ + { kind: "namespace", localName: "THREE", moduleId: "three" }, + { kind: "entry", key: "Mesh", valueText: "Custom" }, +] + +describe("providers", () => { + it("key universe = union of namespace keys and entry keys", () => { + expect([...keyUniverse(sources, nsKeys)].sort()).toEqual(["Box", "Group", "Mesh"]) + }) + it("last writer wins: Mesh -> Custom, Group -> THREE.Group", () => { + expect(providerFor("Mesh", sources, nsKeys)).toBe("Custom") + expect(providerFor("Group", sources, nsKeys)).toBe("THREE.Group") + }) + it("returns undefined for a key no source provides", () => { + expect(providerFor("Nope", sources, nsKeys)).toBeUndefined() + }) +}) From 68be13e002b6804b7b221b293af8ee3628d7fe27 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:07:10 +0200 Subject: [PATCH 11/21] feat(vps3): measure (pass 1) and emit (pass 2) rewrites Also adds packages/vite-plugin-solid-three to the pnpm workspace so its magic-string dependency is installed and resolvable by vitest. --- .../vite-plugin-solid-three/src/rewrite.ts | 48 +++++++++++++++++++ .../test/rewrite.test.ts | 27 +++++++++++ pnpm-lock.yaml | 47 +++++++++++++++++- pnpm-workspace.yaml | 1 + 4 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 packages/vite-plugin-solid-three/src/rewrite.ts create mode 100644 packages/vite-plugin-solid-three/test/rewrite.test.ts diff --git a/packages/vite-plugin-solid-three/src/rewrite.ts b/packages/vite-plugin-solid-three/src/rewrite.ts new file mode 100644 index 00000000..53228224 --- /dev/null +++ b/packages/vite-plugin-solid-three/src/rewrite.ts @@ -0,0 +1,48 @@ +import MagicString from "magic-string" +import { keyUniverse, providerFor } from "./providers.ts" +import type { CatalogueSite } from "./types.ts" + +export interface RewriteResult { + code: string + map: ReturnType +} + +/** Pass 1: turn each catalogue binding into a namespace import of its scaffold. */ +export function rewriteMeasure( + code: string, + sites: CatalogueSite[], + scaffoldIdFor: (site: CatalogueSite) => string, +): RewriteResult { + const s = new MagicString(code) + for (const site of sites) { + const reexport = site.exported ? `\nexport { ${site.binding} }` : "" + s.overwrite( + site.statementStart, + site.statementEnd, + `import * as ${site.binding} from "${scaffoldIdFor(site)}"${reexport}`, + ) + } + return { code: s.toString(), map: s.generateMap({ hires: true }) } +} + +/** Pass 2: narrow each createT argument to the used keys, resolving providers. */ +export function rewriteEmit( + code: string, + sites: CatalogueSite[], + usedKeysFor: (site: CatalogueSite) => Set, + nsKeys: Map, +): RewriteResult { + const s = new MagicString(code) + for (const site of sites) { + const used = usedKeysFor(site) + const universe = keyUniverse(site.sources, nsKeys) + const entries: string[] = [] + for (const key of used) { + if (!universe.has(key)) continue // accessed key not in catalogue — leave to runtime (proxy semantics) + const provider = providerFor(key, site.sources, nsKeys) + if (provider !== undefined) entries.push(`${key}: ${provider}`) + } + s.overwrite(site.argStart, site.argEnd, `{ ${entries.join(", ")} }`) + } + return { code: s.toString(), map: s.generateMap({ hires: true }) } +} diff --git a/packages/vite-plugin-solid-three/test/rewrite.test.ts b/packages/vite-plugin-solid-three/test/rewrite.test.ts new file mode 100644 index 00000000..494bc634 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/rewrite.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" +import { analyzeModule } from "../src/analyze.ts" +import { rewriteMeasure, rewriteEmit } from "../src/rewrite.ts" + +const CODE = `import { createT } from "solid-three" +import * as THREE from "three" +export const T = createT(THREE)` + +describe("rewriteMeasure (pass 1)", () => { + it("replaces the binding statement with a namespace import + re-export", () => { + const { sites } = analyzeModule(CODE, "/c.ts") + const out = rewriteMeasure(CODE, sites, () => "\0scaffold").code + expect(out).toContain(`import * as T from "\0scaffold"`) + expect(out).toContain(`export { T }`) + expect(out).not.toContain("createT(THREE)") + }) +}) + +describe("rewriteEmit (pass 2)", () => { + it("narrows the createT argument to used keys with resolved providers", () => { + const { sites } = analyzeModule(CODE, "/c.ts") + const nsKeys = new Map([["three", ["Mesh", "Group", "Box"]]]) + const out = rewriteEmit(CODE, sites, () => new Set(["Mesh", "Group"]), nsKeys).code + expect(out).toContain("createT({ Mesh: THREE.Mesh, Group: THREE.Group })") + expect(out).not.toContain("createT(THREE)") + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14cefa5a..e2fb8acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,49 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(@vitest/browser-playwright@4.1.7)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + packages/vite-plugin-solid-three: + dependencies: + magic-string: + specifier: ^0.30.0 + version: 0.30.21 + devDependencies: + '@solidjs/testing-library': + specifier: ^0.8.8 + version: 0.8.10(@solidjs/router@0.16.1(solid-js@1.9.13))(solid-js@1.9.13) + '@vitest/browser': + specifier: ^4.1.7 + version: 4.1.7(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7) + '@vitest/browser-playwright': + specifier: ^4.1.7 + version: 4.1.7(playwright@1.60.0)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.7) + playwright: + specifier: ^1.60.0 + version: 1.60.0 + solid-js: + specifier: ^1.8.17 + version: 1.9.13 + solid-three: + specifier: workspace:* + version: link:../.. + three: + specifier: ^0.181.2 + version: 0.181.2 + tsup: + specifier: ^8.0.2 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.4.5 + version: 5.9.3 + vite: + specifier: 6.4.2 + version: 6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-solid: + specifier: 2.11.12 + version: 2.11.12(solid-js@1.9.13)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(@vitest/browser-playwright@4.1.7)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + site: dependencies: '@bigmistqke/repl': @@ -1964,7 +2007,7 @@ packages: solid-js: ^1.8.6 '@solidjs/start@https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152': - resolution: {integrity: sha512-h14+vdw26apDAi/l5HEG769B4RJ3NWjj92npNMM8VUpxaQwb1f9BDxjM9XrxwSa04tpZqs+7ZG8/uL+aEsIAwA==, tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152} + resolution: {tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/start@2152} version: 2.0.0-alpha.3 engines: {node: '>=22'} peerDependencies: @@ -1981,7 +2024,7 @@ packages: optional: true '@solidjs/vite-plugin-nitro-2@https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152': - resolution: {integrity: sha512-EA2P/X3tFngkEq6CE+/VUQP6d812BZx5IK0vaC00+tw+n4voXS11WKkNo/qkxZkkruszV6ulzG4qomJpoIlmcA==, tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152} + resolution: {tarball: https://pkg.pr.new/solidjs/solid-start/@solidjs/vite-plugin-nitro-2@2152} version: 0.3.0 peerDependencies: vite: ^7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ee94979..f5795998 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "site" + - "packages/vite-plugin-solid-three" catalog: "@kobalte/solidbase": ^0.6.3 allowBuilds: From 634a67fdd7f2bcc7e6c015b2094f3e14db2329d3 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:16:47 +0200 Subject: [PATCH 12/21] feat(vps3): Vite plugin with two-pass measure/emit orchestration --- packages/vite-plugin-solid-three/src/index.ts | 116 +++++++++++++++++- .../src/namespace-keys.ts | 14 ++- .../test/fixtures/_harness.ts | 19 +++ .../test/fixtures/fixtures.test.ts | 10 ++ .../test/fixtures/namespace/catalog.ts | 3 + .../test/fixtures/namespace/scene.tsx | 4 + .../test/fixtures/namespace/tsconfig.json | 1 + .../vite-plugin-solid-three/tsconfig.json | 5 +- 8 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 packages/vite-plugin-solid-three/test/fixtures/_harness.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/namespace/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/namespace/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/namespace/tsconfig.json diff --git a/packages/vite-plugin-solid-three/src/index.ts b/packages/vite-plugin-solid-three/src/index.ts index 7f814ebd..35f89305 100644 --- a/packages/vite-plugin-solid-three/src/index.ts +++ b/packages/vite-plugin-solid-three/src/index.ts @@ -1 +1,115 @@ -export const placeholder = true +import { build, type Plugin, type UserConfig } from "vite" +import { analyzeModule } from "./analyze.ts" +import { enumerateNamespaceKeys } from "./namespace-keys.ts" +import { keyUniverse } from "./providers.ts" +import { rewriteEmit, rewriteMeasure } from "./rewrite.ts" +import { decodeScaffoldId, encodeScaffoldId, isScaffoldId, scaffoldSource } from "./scaffold.ts" + +// Module-level coordination between the real build and its nested measure build. +let measuring = false +// moduleId -> siteIndex -> used keys (filled by the measure build, read by the real build). +const measured = new Map>>() +// moduleId -> siteIndex -> scaffold key universe (filled during the measure transform, read by load()). +const scaffoldKeys = new Map>() + +// Public specifier emitted into source (no NUL). resolveId maps it to the internal "\0..." id. +const PUBLIC_PREFIX = "vps3-scaffold:" + +function normalize(id: string): string { + return id.split("?")[0] +} + +/** The specifier emitted into source (drops the leading NUL of the internal scaffold id). */ +function publicScaffoldId(moduleId: string, siteIndex: number): string { + return encodeScaffoldId(moduleId, siteIndex).slice(1) +} + +export default function solidThree(): Plugin { + let userConfig: UserConfig = {} + let root = process.cwd() + + return { + name: "vite-plugin-solid-three", + enforce: "pre", + apply: "build", // dev keeps the runtime proxy + + config(config) { + userConfig = config + }, + configResolved(resolved) { + root = resolved.root + }, + + async buildStart() { + if (measuring) return // we ARE the measurement build — don't recurse + measuring = true + try { + await build({ + ...userConfig, + configFile: false, + logLevel: "silent", + build: { ...userConfig.build, write: false }, + }) + } finally { + measuring = false + } + }, + + resolveId(id) { + if (id.startsWith(PUBLIC_PREFIX)) return "\0" + id + return null + }, + load(id) { + if (!isScaffoldId(id)) return null + const { moduleId, siteIndex } = decodeScaffoldId(id) + const keys = scaffoldKeys.get(moduleId)?.get(siteIndex) ?? [] + return scaffoldSource(keys) + }, + + async transform(code, id) { + const file = normalize(id) + const { sites } = analyzeModule(code, file) + if (sites.length === 0) return null + + if (measuring) { + // Pass 1: build scaffold key universes, rewrite bindings to namespace imports. + const perSite = new Map() + for (const site of sites) { + const nsKeys = new Map() + for (const s of site.sources) { + if (s.kind === "namespace") nsKeys.set(s.moduleId, await enumerateNamespaceKeys(s.moduleId, root)) + } + perSite.set(site.siteIndex, [...keyUniverse(site.sources, nsKeys)]) + } + scaffoldKeys.set(file, perSite) + const result = rewriteMeasure(code, sites, site => publicScaffoldId(file, site.siteIndex)) + return { code: result.code, map: result.map } + } + + // Pass 2 (real build): narrow using the measured used-keys. + const used = measured.get(file) + if (!used) return null + const nsKeys = new Map() + for (const site of sites) + for (const s of site.sources) + if (s.kind === "namespace" && !nsKeys.has(s.moduleId)) + nsKeys.set(s.moduleId, await enumerateNamespaceKeys(s.moduleId, root)) + const result = rewriteEmit(code, sites, site => used.get(site.siteIndex) ?? new Set(), nsKeys) + return { code: result.code, map: result.map } + }, + + generateBundle(_opts, bundle) { + if (!measuring) return // only the measure build records usage + for (const chunk of Object.values(bundle)) { + if (chunk.type !== "chunk") continue + for (const [mid, mod] of Object.entries(chunk.modules)) { + if (!isScaffoldId(mid)) continue + const { moduleId, siteIndex } = decodeScaffoldId(mid) + const perSite = measured.get(moduleId) ?? new Map>() + perSite.set(siteIndex, new Set(mod.renderedExports)) + measured.set(moduleId, perSite) + } + } + }, + } +} diff --git a/packages/vite-plugin-solid-three/src/namespace-keys.ts b/packages/vite-plugin-solid-three/src/namespace-keys.ts index 003ec997..b5bacff8 100644 --- a/packages/vite-plugin-solid-three/src/namespace-keys.ts +++ b/packages/vite-plugin-solid-three/src/namespace-keys.ts @@ -14,16 +14,26 @@ export function enumerateNamespaceKeys(moduleId: string, root: string): Promise< return pending } +// A catalogue key must be referenceable as a member expression (`NS.Key`) and +// emitted as an `export const Key` in the scaffold, so it must be a valid JS +// identifier. CJS builds surfaced through ESM interop can leak non-member keys +// such as `module.exports`; drop anything that isn't a plain identifier. +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +function exportNames(ns: Record): string[] { + return Object.keys(ns).filter(k => k !== "default" && !k.startsWith("__") && IDENTIFIER.test(k)) +} + async function load(moduleId: string, root: string): Promise { const require = createRequire(pathToFileURL(`${root}/package.json`)) const resolved = require.resolve(moduleId) const ns = await import(pathToFileURL(resolved).href) - let keys = Object.keys(ns).filter(k => k !== "default" && !k.startsWith("__")) + let keys = exportNames(ns) // CJS modules imported via ESM interop often surface only `{ default }`. // When that happens, fall back to the default export if it is a plain object // holding the actual namespace members (e.g. three's CJS build). if (keys.length === 0 && ns.default != null && typeof ns.default === "object") { - keys = Object.keys(ns.default).filter(k => k !== "default" && !k.startsWith("__")) + keys = exportNames(ns.default as Record) } return keys } diff --git a/packages/vite-plugin-solid-three/test/fixtures/_harness.ts b/packages/vite-plugin-solid-three/test/fixtures/_harness.ts new file mode 100644 index 00000000..115e3b6d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/_harness.ts @@ -0,0 +1,19 @@ +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { build } from "vite" +import solid from "vite-plugin-solid" +import solidThree from "../../src/index.ts" + +const here = dirname(fileURLToPath(import.meta.url)) + +export async function buildFixture(name: string, entry = "scene.tsx"): Promise { + const root = resolve(here, name) + const output = await build({ + root, + logLevel: "silent", + plugins: [solidThree(), solid()], + build: { write: false, minify: false, lib: { entry: resolve(root, entry), formats: ["es"], fileName: "out" } }, + }) + const chunks = (Array.isArray(output) ? output : [output]).flatMap(o => ("output" in o ? o.output : [])) + return chunks.filter(c => c.type === "chunk").map(c => (c as { code: string }).code).join("\n") +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts new file mode 100644 index 00000000..f8b31c2a --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest" +import { buildFixture } from "./_harness.ts" + +describe("fixture matrix", () => { + it("namespace: narrows createT(THREE) to the used class", async () => { + const code = await buildFixture("namespace") + expect(code).toContain("Mesh") + expect(code).not.toContain("BoxGeometry") + }) +}) diff --git a/packages/vite-plugin-solid-three/test/fixtures/namespace/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/namespace/catalog.ts new file mode 100644 index 00000000..b5655b02 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/namespace/catalog.ts @@ -0,0 +1,3 @@ +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) diff --git a/packages/vite-plugin-solid-three/test/fixtures/namespace/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/namespace/scene.tsx new file mode 100644 index 00000000..f8872097 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/namespace/scene.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function Scene() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/namespace/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/namespace/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/namespace/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/tsconfig.json b/packages/vite-plugin-solid-three/tsconfig.json index dbbb17e2..298ea528 100644 --- a/packages/vite-plugin-solid-three/tsconfig.json +++ b/packages/vite-plugin-solid-three/tsconfig.json @@ -9,5 +9,8 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["src", "test"] + "include": ["src", "test"], + // Build-input fixtures carry their own tsconfig (solid-js JSX). They are exercised + // by `vite build` in the test runner, not type-checked against the library config. + "exclude": ["test/fixtures"] } From 06fefd2764d525768a7656493e7a48379c65bc1b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:23:03 +0200 Subject: [PATCH 13/21] =?UTF-8?q?refactor(vps3):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20defensive=20renderedExports,=20normalize=20hash,=20?= =?UTF-8?q?clarify=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vite-plugin-solid-three/src/index.ts | 8 ++++++-- packages/vite-plugin-solid-three/src/namespace-keys.ts | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin-solid-three/src/index.ts b/packages/vite-plugin-solid-three/src/index.ts index 35f89305..6b4019d0 100644 --- a/packages/vite-plugin-solid-three/src/index.ts +++ b/packages/vite-plugin-solid-three/src/index.ts @@ -16,7 +16,7 @@ const scaffoldKeys = new Map>() const PUBLIC_PREFIX = "vps3-scaffold:" function normalize(id: string): string { - return id.split("?")[0] + return id.split("?")[0].split("#")[0] } /** The specifier emitted into source (drops the leading NUL of the internal scaffold id). */ @@ -44,6 +44,10 @@ export default function solidThree(): Plugin { if (measuring) return // we ARE the measurement build — don't recurse measuring = true try { + // Replay the user's config (including their plugins — e.g. vite-plugin-solid, + // which the measure build needs to transform JSX) so the measurement graph + // matches the real one. Side effect: the user's plugins run a second time; + // plugins with their own build-side effects will fire twice during a build. await build({ ...userConfig, configFile: false, @@ -106,7 +110,7 @@ export default function solidThree(): Plugin { if (!isScaffoldId(mid)) continue const { moduleId, siteIndex } = decodeScaffoldId(mid) const perSite = measured.get(moduleId) ?? new Map>() - perSite.set(siteIndex, new Set(mod.renderedExports)) + perSite.set(siteIndex, new Set(mod.renderedExports ?? [])) measured.set(moduleId, perSite) } } diff --git a/packages/vite-plugin-solid-three/src/namespace-keys.ts b/packages/vite-plugin-solid-three/src/namespace-keys.ts index b5bacff8..6bc823dc 100644 --- a/packages/vite-plugin-solid-three/src/namespace-keys.ts +++ b/packages/vite-plugin-solid-three/src/namespace-keys.ts @@ -21,6 +21,8 @@ export function enumerateNamespaceKeys(moduleId: string, root: string): Promise< const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ function exportNames(ns: Record): string[] { + // `default` and `__`-prefixed names (e.g. `__esModule`) are interop bookkeeping, + // not catalogue members — they pass the identifier test but must still be dropped. return Object.keys(ns).filter(k => k !== "default" && !k.startsWith("__") && IDENTIFIER.test(k)) } From 27fbfe99f4cdcaddbe7b619e7eefecc7eb0c817b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:26:21 +0200 Subject: [PATCH 14/21] =?UTF-8?q?test(vps3):=20fixture=20matrix=20?= =?UTF-8?q?=E2=80=94=20literal,=20override,=20dynamic-bail,=20union?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/fixtures/dynamic/catalog.ts | 3 +++ .../test/fixtures/dynamic/scene.tsx | 3 +++ .../test/fixtures/dynamic/tsconfig.json | 1 + .../test/fixtures/fixtures.test.ts | 24 +++++++++++++++++++ .../test/fixtures/literal/catalog.ts | 3 +++ .../test/fixtures/literal/scene.tsx | 4 ++++ .../test/fixtures/literal/tsconfig.json | 1 + .../test/fixtures/override/catalog.ts | 6 +++++ .../test/fixtures/override/scene.tsx | 4 ++++ .../test/fixtures/override/tsconfig.json | 1 + .../test/fixtures/union/a.tsx | 4 ++++ .../test/fixtures/union/b.tsx | 4 ++++ .../test/fixtures/union/catalog.ts | 3 +++ .../test/fixtures/union/entry.tsx | 2 ++ .../test/fixtures/union/tsconfig.json | 1 + 15 files changed, 64 insertions(+) create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic/tsconfig.json create mode 100644 packages/vite-plugin-solid-three/test/fixtures/literal/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/literal/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/literal/tsconfig.json create mode 100644 packages/vite-plugin-solid-three/test/fixtures/override/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/override/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/override/tsconfig.json create mode 100644 packages/vite-plugin-solid-three/test/fixtures/union/a.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/union/b.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/union/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/union/entry.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/union/tsconfig.json diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/dynamic/catalog.ts new file mode 100644 index 00000000..b5655b02 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic/catalog.ts @@ -0,0 +1,3 @@ +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/dynamic/scene.tsx new file mode 100644 index 00000000..baa01988 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic/scene.tsx @@ -0,0 +1,3 @@ +import { T } from "./catalog.ts" +const key = (globalThis as Record).key as string +export const used = (T as Record)[key] diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/dynamic/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts index f8b31c2a..e1fd67f1 100644 --- a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts +++ b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts @@ -8,3 +8,27 @@ describe("fixture matrix", () => { expect(code).not.toContain("BoxGeometry") }) }) + +describe("fixture matrix — shapes", () => { + it("literal: drops the unused custom class", async () => { + const code = await buildFixture("literal") + expect(code).toContain("Mesh") + expect(code).not.toContain("BoxGeometry") + }) + it("override: last-write-wins keeps CustomMesh + Group, drops unused three", async () => { + const code = await buildFixture("override") + expect(code).toContain("customMeshMarker") + expect(code).toContain("Group") + expect(code).not.toContain("BoxGeometry") + }) + it("dynamic: bails to full catalogue, build still works", async () => { + const code = await buildFixture("dynamic") + expect(code).toContain("BoxGeometry") // not narrowed + }) + it("union: keeps the union across files", async () => { + const code = await buildFixture("union", "entry.tsx") + expect(code).toContain("Mesh") + expect(code).toContain("Group") + expect(code).not.toContain("TorusKnotGeometry") + }) +}) diff --git a/packages/vite-plugin-solid-three/test/fixtures/literal/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/literal/catalog.ts new file mode 100644 index 00000000..f114838f --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/literal/catalog.ts @@ -0,0 +1,3 @@ +import { Mesh, BoxGeometry } from "three" +import { createT } from "solid-three" +export const T = createT({ Used: Mesh, Unused: BoxGeometry }) diff --git a/packages/vite-plugin-solid-three/test/fixtures/literal/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/literal/scene.tsx new file mode 100644 index 00000000..d33859a9 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/literal/scene.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function Scene() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/literal/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/literal/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/literal/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/fixtures/override/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/override/catalog.ts new file mode 100644 index 00000000..478d2c91 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/override/catalog.ts @@ -0,0 +1,6 @@ +import * as THREE from "three" +import { createT } from "solid-three" +class CustomMesh { + customMeshMarker() {} +} +export const T = createT({ ...THREE, Mesh: CustomMesh }) diff --git a/packages/vite-plugin-solid-three/test/fixtures/override/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/override/scene.tsx new file mode 100644 index 00000000..3fdcae93 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/override/scene.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function Scene() { + return [, ] +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/override/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/override/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/override/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/fixtures/union/a.tsx b/packages/vite-plugin-solid-three/test/fixtures/union/a.tsx new file mode 100644 index 00000000..23238b38 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/union/a.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function A() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/union/b.tsx b/packages/vite-plugin-solid-three/test/fixtures/union/b.tsx new file mode 100644 index 00000000..d8cdb082 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/union/b.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function B() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/union/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/union/catalog.ts new file mode 100644 index 00000000..b5655b02 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/union/catalog.ts @@ -0,0 +1,3 @@ +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) diff --git a/packages/vite-plugin-solid-three/test/fixtures/union/entry.tsx b/packages/vite-plugin-solid-three/test/fixtures/union/entry.tsx new file mode 100644 index 00000000..3af83865 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/union/entry.tsx @@ -0,0 +1,2 @@ +export { A } from "./a.tsx" +export { B } from "./b.tsx" diff --git a/packages/vite-plugin-solid-three/test/fixtures/union/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/union/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/union/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } From 5371bcfee02672b7122e05e4778efb34aeed1268 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:29:40 +0200 Subject: [PATCH 15/21] test(vps3): code-split and dynamic-import fixtures --- .../test/fixtures/code-split/Heavy.tsx | 4 ++++ .../test/fixtures/code-split/catalog.ts | 3 +++ .../test/fixtures/code-split/scene.tsx | 5 +++++ .../test/fixtures/code-split/tsconfig.json | 1 + .../test/fixtures/dynamic-import/catalog.ts | 3 +++ .../test/fixtures/dynamic-import/scene.tsx | 3 +++ .../test/fixtures/dynamic-import/scenes/one.tsx | 4 ++++ .../test/fixtures/dynamic-import/tsconfig.json | 1 + .../test/fixtures/fixtures.test.ts | 9 +++++++++ 9 files changed, 33 insertions(+) create mode 100644 packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/code-split/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/code-split/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/code-split/tsconfig.json create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic-import/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scenes/one.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/dynamic-import/tsconfig.json diff --git a/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx b/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx new file mode 100644 index 00000000..76ef7c8a --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function Heavy() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/code-split/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/code-split/catalog.ts new file mode 100644 index 00000000..b5655b02 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/code-split/catalog.ts @@ -0,0 +1,3 @@ +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) diff --git a/packages/vite-plugin-solid-three/test/fixtures/code-split/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/code-split/scene.tsx new file mode 100644 index 00000000..43f432c1 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/code-split/scene.tsx @@ -0,0 +1,5 @@ +import { lazy } from "solid-js" +const Heavy = lazy(() => import("./Heavy.tsx")) +export function Scene() { + return Heavy +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/code-split/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/code-split/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/code-split/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/catalog.ts new file mode 100644 index 00000000..b5655b02 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/catalog.ts @@ -0,0 +1,3 @@ +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scene.tsx new file mode 100644 index 00000000..f12fddd3 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scene.tsx @@ -0,0 +1,3 @@ +export function load(n: string) { + return import(`./scenes/${n}.tsx`) +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scenes/one.tsx b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scenes/one.tsx new file mode 100644 index 00000000..2b44dd54 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/scenes/one.tsx @@ -0,0 +1,4 @@ +import { T } from "../catalog.ts" +export function One() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/dynamic-import/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts index e1fd67f1..c4abc889 100644 --- a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts +++ b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts @@ -32,3 +32,12 @@ describe("fixture matrix — shapes", () => { expect(code).not.toContain("TorusKnotGeometry") }) }) + +describe("fixture matrix — code splitting", () => { + it("code-split: a class only in a lazy chunk survives", async () => { + expect(await buildFixture("code-split")).toContain("DodecahedronGeometry") + }) + it("dynamic-import: a class only in a dynamically-imported scene survives", async () => { + expect(await buildFixture("dynamic-import")).toContain("TorusKnotGeometry") + }) +}) From 7384388ca983ad5db507b9377c0c0e7acdcedca7 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:41:14 +0200 Subject: [PATCH 16/21] test(vps3): browser soundness oracle --- .../test/oracle/global-setup.ts | 64 +++++++++++++++++++ .../test/oracle/oracle.generated.json | 8 +++ .../test/oracle/oracle.test.tsx | 55 ++++++++++++++++ .../test/oracle/scene.fixture.tsx | 16 +++++ .../test/oracle/shim.ts | 24 +++++++ .../vite-plugin-solid-three/tsconfig.json | 3 +- .../vitest.browser.config.ts | 37 +++++++++++ 7 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 packages/vite-plugin-solid-three/test/oracle/global-setup.ts create mode 100644 packages/vite-plugin-solid-three/test/oracle/oracle.generated.json create mode 100644 packages/vite-plugin-solid-three/test/oracle/oracle.test.tsx create mode 100644 packages/vite-plugin-solid-three/test/oracle/scene.fixture.tsx create mode 100644 packages/vite-plugin-solid-three/test/oracle/shim.ts create mode 100644 packages/vite-plugin-solid-three/vitest.browser.config.ts diff --git a/packages/vite-plugin-solid-three/test/oracle/global-setup.ts b/packages/vite-plugin-solid-three/test/oracle/global-setup.ts new file mode 100644 index 00000000..b3d7ae24 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/oracle/global-setup.ts @@ -0,0 +1,64 @@ +// Global setup for the browser soundness oracle. +// +// Before the browser test runs, build the oracle scene fixture through the real +// plugin (same build shape as `test/fixtures/_harness.ts`), extract the +// build-narrowed catalogue keys from the emitted `createT({ ... })` call, and +// persist them to `oracle.generated.json`. The browser test then asserts that +// every catalogue key the running scene actually accesses is contained in this +// narrowed set. + +import { writeFileSync } from "node:fs" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { build } from "vite" +import solid from "vite-plugin-solid" +import solidThree from "../../src/index.ts" + +const here = dirname(fileURLToPath(import.meta.url)) + +function extractNarrowedKeys(code: string): string[] { + const match = code.match(/createT\(\s*\{([^}]*)\}\s*\)/) + if (!match) { + throw new Error("oracle global-setup: no createT({ ... }) call found in emitted output") + } + const body = match[1] + const keys: string[] = [] + for (const rawEntry of body.split(",")) { + const entry = rawEntry.trim() + if (entry.length === 0) continue + // Shorthand (`Mesh`) or longhand (`Mesh: value`); identifier before any `:`. + const identifier = entry.split(":")[0].trim() + if (/^[A-Za-z_$][\w$]*$/.test(identifier)) { + keys.push(identifier) + } + } + return keys +} + +export default async function setup() { + const root = here + const output = await build({ + root, + logLevel: "silent", + plugins: [solidThree(), solid()], + build: { + write: false, + minify: false, + lib: { entry: resolve(root, "scene.fixture.tsx"), formats: ["es"], fileName: "out" }, + }, + }) + const chunks = (Array.isArray(output) ? output : [output]).flatMap(o => + "output" in o ? o.output : [], + ) + const code = chunks + .filter(c => c.type === "chunk") + .map(c => (c as { code: string }).code) + .join("\n") + + const keys = extractNarrowedKeys(code) + if (keys.length === 0) { + throw new Error("oracle global-setup: extracted narrowed key set is empty") + } + + writeFileSync(resolve(root, "oracle.generated.json"), JSON.stringify({ keys }, null, 2)) +} diff --git a/packages/vite-plugin-solid-three/test/oracle/oracle.generated.json b/packages/vite-plugin-solid-three/test/oracle/oracle.generated.json new file mode 100644 index 00000000..589f602b --- /dev/null +++ b/packages/vite-plugin-solid-three/test/oracle/oracle.generated.json @@ -0,0 +1,8 @@ +{ + "keys": [ + "BoxGeometry", + "Group", + "Mesh", + "MeshStandardMaterial" + ] +} \ No newline at end of file diff --git a/packages/vite-plugin-solid-three/test/oracle/oracle.test.tsx b/packages/vite-plugin-solid-three/test/oracle/oracle.test.tsx new file mode 100644 index 00000000..4d180b56 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/oracle/oracle.test.tsx @@ -0,0 +1,55 @@ +import { render } from "@solidjs/testing-library" +import * as THREE from "three" +import { afterEach, beforeAll, expect, test, vi } from "vitest" +import oracle from "./oracle.generated.json" +import { requestedKeys, wrap } from "./shim" + +// Route solid-three's `createT` through the instrumentation shim, so every +// catalogue key the running scene accesses is recorded into `requestedKeys`. +// `importOriginal` gives us the real factory to wrap (not a recursion). +vi.mock("solid-three", async importOriginal => { + const real = await importOriginal() + return { ...real, createT: wrap(real.createT) } +}) + +// `Canvas` and `Scene` must come from the SAME mocked module graph so they +// share one reconciler context; importing a second harness bundle yields +// "hooks called outside ". +import { Canvas } from "solid-three" +import { Scene } from "./scene.fixture" + +beforeAll(() => { + // A render-loop raycast may throw asynchronously about a missing + // boundingSphere after our synchronous assertion has already run. That race + // is unrelated to soundness; swallow exactly that error. + window.addEventListener("error", e => { + if (e.message?.includes("boundingSphere")) e.preventDefault() + }) +}) + +afterEach(() => { + requestedKeys.clear() +}) + +test("soundness oracle: every runtime-accessed catalogue key survives narrowing", async () => { + render(() => ( + + + + )) + + // Let the scene graph mount. + await new Promise(resolve => setTimeout(resolve, 100)) + + const narrowed = new Set(oracle.keys) + // Only consider keys that are genuine THREE classes (the proxy also sees + // framework/internal property probes that are not catalogue classes). + const requested = [...requestedKeys].filter(key => key in THREE) + + // Non-vacuous: the scene must actually have exercised some catalogue classes. + expect(requested.length).toBeGreaterThan(0) + + // Soundness: narrowing must never drop a class the running scene uses. + const leaked = requested.filter(key => !narrowed.has(key)) + expect(leaked).toEqual([]) +}) diff --git a/packages/vite-plugin-solid-three/test/oracle/scene.fixture.tsx b/packages/vite-plugin-solid-three/test/oracle/scene.fixture.tsx new file mode 100644 index 00000000..66e577b3 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/oracle/scene.fixture.tsx @@ -0,0 +1,16 @@ +import * as THREE from "three" +import { createT } from "solid-three" + +const T = createT(THREE) + +export function Scene() { + return ( + <> + + + + + + + ) +} diff --git a/packages/vite-plugin-solid-three/test/oracle/shim.ts b/packages/vite-plugin-solid-three/test/oracle/shim.ts new file mode 100644 index 00000000..dfd6a544 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/oracle/shim.ts @@ -0,0 +1,24 @@ +// Instrumentation shim for the soundness oracle. +// +// `requestedKeys` records every catalogue property the running scene touches. +// `wrap` takes the real `createT` factory and returns a drop-in replacement +// that builds the real catalogue, then proxies the returned `T` object so each +// property access is logged before delegating to the real value. + +export const requestedKeys = new Set() + +type CreateT = (catalogue: Record) => Record + +export function wrap(realCreateT: CreateT): CreateT { + return catalogue => { + const real = realCreateT(catalogue) + return new Proxy(real, { + get(target, prop, receiver) { + if (typeof prop === "string") { + requestedKeys.add(prop) + } + return Reflect.get(target, prop, receiver) + }, + }) + } +} diff --git a/packages/vite-plugin-solid-three/tsconfig.json b/packages/vite-plugin-solid-three/tsconfig.json index 298ea528..b3cc7f79 100644 --- a/packages/vite-plugin-solid-three/tsconfig.json +++ b/packages/vite-plugin-solid-three/tsconfig.json @@ -12,5 +12,6 @@ "include": ["src", "test"], // Build-input fixtures carry their own tsconfig (solid-js JSX). They are exercised // by `vite build` in the test runner, not type-checked against the library config. - "exclude": ["test/fixtures"] + // The browser oracle is likewise solid-js JSX exercised in a real browser, not tsc. + "exclude": ["test/fixtures", "test/oracle"] } diff --git a/packages/vite-plugin-solid-three/vitest.browser.config.ts b/packages/vite-plugin-solid-three/vitest.browser.config.ts new file mode 100644 index 00000000..c06bab59 --- /dev/null +++ b/packages/vite-plugin-solid-three/vitest.browser.config.ts @@ -0,0 +1,37 @@ +import { playwright } from "@vitest/browser-playwright" +import solid from "vite-plugin-solid" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + plugins: [solid({ hot: false })], + optimizeDeps: { + // Pre-bundle the harness deps plus solid-three's transitive runtime deps, + // otherwise Vite discovers them mid-run and reloads the test (flaky). + include: [ + "@solidjs/testing-library", + "three", + "solid-three", + "@solid-primitives/resize-observer", + "@bigmistqke/solid-whenever", + ], + }, + test: { + globalSetup: ["./test/oracle/global-setup.ts"], + include: ["test/oracle/**/*.test.tsx"], + // `vite-plugin-solid` defaults `test.environment` to `'jsdom'` when unset, + // which makes vitest exit 1 because jsdom isn't installed. Pin "node" so the + // Solid plugin keeps its hands off the field — DOM work happens in the real + // browser via the `browser` block below. + environment: "node", + browser: { + enabled: true, + provider: playwright({ + launchOptions: { + args: ["--use-gl=swiftshader", "--enable-unsafe-swiftshader"], + }, + }), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, +}) From 0aaac5b187f7083ab1746afd799b81033ed8ce88 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 17:50:15 +0200 Subject: [PATCH 17/21] docs(vps3): README; local tsup config for a clean dist build --- packages/vite-plugin-solid-three/README.md | 53 +++++++++++++++++++ packages/vite-plugin-solid-three/package.json | 2 +- .../vite-plugin-solid-three/tsup.config.ts | 10 ++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 packages/vite-plugin-solid-three/README.md create mode 100644 packages/vite-plugin-solid-three/tsup.config.ts diff --git a/packages/vite-plugin-solid-three/README.md b/packages/vite-plugin-solid-three/README.md new file mode 100644 index 00000000..46197b6c --- /dev/null +++ b/packages/vite-plugin-solid-three/README.md @@ -0,0 +1,53 @@ +# vite-plugin-solid-three + +Build-time tree-shaking for [`solid-three`](https://github.com/solidjs-community/solid-three). Rewrites `createT(THREE)` into a curated catalogue containing only the classes your app actually reaches through `T`, so your bundler can drop the rest. + +## Install + +```bash +pnpm add -D vite-plugin-solid-three +``` + +## Usage + +```ts +// vite.config.ts +import solidThree from "vite-plugin-solid-three" +import solid from "vite-plugin-solid" + +export default { + plugins: [solidThree(), solid()], +} +``` + +That's the whole setup. You keep writing the concise `const T = createT(THREE)`; the plugin handles the rest on production builds. Dev is untouched — the runtime proxy is used as normal. + +## How it works + +The plugin lets the bundler do the analysis. On a production build it runs two passes: + +1. **Measure.** It temporarily turns your catalogue into a namespace and runs a throwaway build. The bundler's own tree-shaking reports exactly which keys (`T.Mesh`, `T.BoxGeometry`, …) survive — across files, dynamic imports, and code-split chunks. +2. **Emit.** It rewrites `createT(THREE)` to `createT({ Mesh: THREE.Mesh, … })` containing only the measured keys. `createT` stays in your output, so runtime behaviour and debugging are unchanged; the unused three classes are now unreferenced and the bundler shakes them out. + +Because the bundler measures real usage, this is exact rather than over-approximate, and it is sound by construction: any access the bundler can't resolve statically deopts to keeping the whole catalogue. + +## What it narrows + +It narrows catalogues whose keys it can read statically: + +- `createT(THREE)` — and any other namespace import (e.g. `createT(WEBGPU)`). +- `createT({ MyMesh, ...THREE })` — object literals, spreads (last writer wins on key collisions), and verbatim getters. Custom classes are kept by their provider; unused ones drop. + +## What it leaves alone + +When narrowing isn't statically safe, the plugin leaves `createT(...)` completely untouched — full catalogue, full runtime dynamism. That happens for: + +- A non-enumerable catalogue source: `createT(store)`, `createT(someVariable)`, a computed key, or a spread of a runtime object. +- Dynamic *usage*: if any code accesses `T[someExpression]` or passes `T` around as a value, the bundler keeps the whole catalogue for that build. Sound, just not optimized. + +So you never have to opt in or annotate anything — the plugin optimizes what it can prove and gets out of the way otherwise. + +## Requirements + +- Vite (build step). The mechanism is Rollup-native; other bundlers aren't supported. +- `typescript` is used to parse your catalogue module (a peer dependency). diff --git a/packages/vite-plugin-solid-three/package.json b/packages/vite-plugin-solid-three/package.json index 4261018e..4c84d4d0 100644 --- a/packages/vite-plugin-solid-three/package.json +++ b/packages/vite-plugin-solid-three/package.json @@ -8,7 +8,7 @@ "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }, "files": ["dist/**"], "scripts": { - "build": "tsup src/index.ts --format esm --dts", + "build": "tsup", "test": "vitest run", "test:oracle": "vitest run --config vitest.browser.config.ts", "test:ts": "tsc --noEmit -p tsconfig.json && vitest run" diff --git a/packages/vite-plugin-solid-three/tsup.config.ts b/packages/vite-plugin-solid-three/tsup.config.ts new file mode 100644 index 00000000..2e887bc8 --- /dev/null +++ b/packages/vite-plugin-solid-three/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup" + +// Local config so tsup does not walk up and inherit the root solid-three build +// config (which targets the library with Solid JSX output and multiple entries). +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, +}) From 4aafad17b83b9689ea830f3404df21ab9354a011 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 18:04:44 +0200 Subject: [PATCH 18/21] fix(vps3): emit verbatim getters as members; address final review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getter/method catalogue entries carried their full member text, but emit prefixed it with `key:` — producing invalid syntax (`createT({ Foo: get Foo(){…} })`) and a failing build. The entry source now records `verbatim` and emit pushes such members as-is. Adds a getter fixture (the emit side was untested) and an escape fixture proving the whole-value-escape deopt keeps the full catalogue. Also clears coordination state per build and documents the one-build-per-process limitation. --- packages/vite-plugin-solid-three/README.md | 1 + .../vite-plugin-solid-three/src/analyze.ts | 3 ++- packages/vite-plugin-solid-three/src/index.ts | 6 ++++++ .../vite-plugin-solid-three/src/providers.ts | 19 +++++++++++++++---- .../vite-plugin-solid-three/src/rewrite.ts | 4 +++- packages/vite-plugin-solid-three/src/types.ts | 4 +++- .../test/fixtures/escape/catalog.ts | 3 +++ .../test/fixtures/escape/scene.tsx | 7 +++++++ .../test/fixtures/escape/tsconfig.json | 1 + .../test/fixtures/fixtures.test.ts | 12 ++++++++++++ .../test/fixtures/getter/catalog.ts | 8 ++++++++ .../test/fixtures/getter/scene.tsx | 4 ++++ .../test/fixtures/getter/tsconfig.json | 1 + .../test/providers.test.ts | 8 ++++++-- 14 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 packages/vite-plugin-solid-three/test/fixtures/escape/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/escape/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/escape/tsconfig.json create mode 100644 packages/vite-plugin-solid-three/test/fixtures/getter/catalog.ts create mode 100644 packages/vite-plugin-solid-three/test/fixtures/getter/scene.tsx create mode 100644 packages/vite-plugin-solid-three/test/fixtures/getter/tsconfig.json diff --git a/packages/vite-plugin-solid-three/README.md b/packages/vite-plugin-solid-three/README.md index 46197b6c..ab4c3faa 100644 --- a/packages/vite-plugin-solid-three/README.md +++ b/packages/vite-plugin-solid-three/README.md @@ -51,3 +51,4 @@ So you never have to opt in or annotate anything — the plugin optimizes what i - Vite (build step). The mechanism is Rollup-native; other bundlers aren't supported. - `typescript` is used to parse your catalogue module (a peer dependency). +- One build per process at a time. The measurement pass runs a nested build coordinated via process-global state, so don't run concurrent builds (e.g. parallel multi-config) in the same process. Vite builds are sequential by default. diff --git a/packages/vite-plugin-solid-three/src/analyze.ts b/packages/vite-plugin-solid-three/src/analyze.ts index 3afb4cde..55a5e320 100644 --- a/packages/vite-plugin-solid-three/src/analyze.ts +++ b/packages/vite-plugin-solid-three/src/analyze.ts @@ -138,7 +138,8 @@ function classifyObject( } else if (ts.isShorthandPropertyAssignment(prop)) { sources.push({ kind: "entry", key, valueText: key }) } else if (ts.isGetAccessorDeclaration(prop) || ts.isMethodDeclaration(prop)) { - sources.push({ kind: "entry", key, valueText: prop.getText(sf) }) // verbatim getter/method + // verbatim getter/method — its text already declares its own key + sources.push({ kind: "entry", key, valueText: prop.getText(sf), verbatim: true }) } else { return { bail: "unsupported property" } } diff --git a/packages/vite-plugin-solid-three/src/index.ts b/packages/vite-plugin-solid-three/src/index.ts index 6b4019d0..e3e926bb 100644 --- a/packages/vite-plugin-solid-three/src/index.ts +++ b/packages/vite-plugin-solid-three/src/index.ts @@ -6,6 +6,9 @@ import { rewriteEmit, rewriteMeasure } from "./rewrite.ts" import { decodeScaffoldId, encodeScaffoldId, isScaffoldId, scaffoldSource } from "./scaffold.ts" // Module-level coordination between the real build and its nested measure build. +// NOTE: this couples a build to its nested measure build by process-global state, +// so only one build may run per process at a time. Vite builds are sequential by +// default; do not run concurrent builds (e.g. parallel multi-config) in one process. let measuring = false // moduleId -> siteIndex -> used keys (filled by the measure build, read by the real build). const measured = new Map>>() @@ -42,6 +45,9 @@ export default function solidThree(): Plugin { async buildStart() { if (measuring) return // we ARE the measurement build — don't recurse + // Fresh state for this build (clears anything left by a prior build in the process). + measured.clear() + scaffoldKeys.clear() measuring = true try { // Replay the user's config (including their plugins — e.g. vite-plugin-solid, diff --git a/packages/vite-plugin-solid-three/src/providers.ts b/packages/vite-plugin-solid-three/src/providers.ts index d3faf2e5..6aa60019 100644 --- a/packages/vite-plugin-solid-three/src/providers.ts +++ b/packages/vite-plugin-solid-three/src/providers.ts @@ -11,12 +11,23 @@ export function keyUniverse(sources: CatalogueSource[], nsKeys: NsKeys): Set= 0; i--) { const s = sources[i] - if (s.kind === "entry" && s.key === key) return s.valueText - if (s.kind === "namespace" && (nsKeys.get(s.moduleId) ?? []).includes(key)) return `${s.localName}.${key}` + if (s.kind === "entry" && s.key === key) return { text: s.valueText, verbatim: s.verbatim ?? false } + if (s.kind === "namespace" && (nsKeys.get(s.moduleId) ?? []).includes(key)) { + return { text: `${s.localName}.${key}`, verbatim: false } + } } return undefined } diff --git a/packages/vite-plugin-solid-three/src/rewrite.ts b/packages/vite-plugin-solid-three/src/rewrite.ts index 53228224..ff9c233f 100644 --- a/packages/vite-plugin-solid-three/src/rewrite.ts +++ b/packages/vite-plugin-solid-three/src/rewrite.ts @@ -40,7 +40,9 @@ export function rewriteEmit( for (const key of used) { if (!universe.has(key)) continue // accessed key not in catalogue — leave to runtime (proxy semantics) const provider = providerFor(key, site.sources, nsKeys) - if (provider !== undefined) entries.push(`${key}: ${provider}`) + if (provider === undefined) continue + // A verbatim member (getter/method) already declares its own key. + entries.push(provider.verbatim ? provider.text : `${key}: ${provider.text}`) } s.overwrite(site.argStart, site.argEnd, `{ ${entries.join(", ")} }`) } diff --git a/packages/vite-plugin-solid-three/src/types.ts b/packages/vite-plugin-solid-three/src/types.ts index 3aa33694..ed0b3b9f 100644 --- a/packages/vite-plugin-solid-three/src/types.ts +++ b/packages/vite-plugin-solid-three/src/types.ts @@ -1,7 +1,9 @@ // A catalogue is an ordered list of sources (last writer wins on key collisions). export type CatalogueSource = | { kind: "namespace"; localName: string; moduleId: string } // createT(THREE) or {...THREE} - | { kind: "entry"; key: string; valueText: string } // { Mesh: X } or { get Foo(){…} } (verbatim) + // `{ Mesh: X }` (valueText "X") or a verbatim member `{ get Foo(){…} }` / `{ m(){…} }` + // (valueText is the whole member text and `verbatim` is true — it declares its own key). + | { kind: "entry"; key: string; valueText: string; verbatim?: boolean } export interface CatalogueSite { binding: string // result binding name, e.g. "T" diff --git a/packages/vite-plugin-solid-three/test/fixtures/escape/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/escape/catalog.ts new file mode 100644 index 00000000..b5655b02 --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/escape/catalog.ts @@ -0,0 +1,3 @@ +import * as THREE from "three" +import { createT } from "solid-three" +export const T = createT(THREE) diff --git a/packages/vite-plugin-solid-three/test/fixtures/escape/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/escape/scene.tsx new file mode 100644 index 00000000..de55d8ff --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/escape/scene.tsx @@ -0,0 +1,7 @@ +import { T } from "./catalog.ts" +// T escapes as a whole value into a function — the bundler can't see which keys +// are used, so it must keep the entire catalogue (sound deopt). +function sink(value: unknown) { + return value +} +export const leaked = sink(T) diff --git a/packages/vite-plugin-solid-three/test/fixtures/escape/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/escape/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/escape/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts index c4abc889..d1f21045 100644 --- a/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts +++ b/packages/vite-plugin-solid-three/test/fixtures/fixtures.test.ts @@ -9,6 +9,18 @@ describe("fixture matrix", () => { }) }) +describe("fixture matrix — verbatim getter & escape", () => { + it("getter: a verbatim getter entry emits valid code and keeps its provider", async () => { + const code = await buildFixture("getter") + expect(code).toContain("getterMarker") // the getter's returned class survived + expect(code).not.toContain("BoxGeometry") // unused three dropped + }) + it("escape: T passed as a whole value keeps the full catalogue (sound deopt)", async () => { + const code = await buildFixture("escape") + expect(code).toContain("BoxGeometry") // not narrowed + }) +}) + describe("fixture matrix — shapes", () => { it("literal: drops the unused custom class", async () => { const code = await buildFixture("literal") diff --git a/packages/vite-plugin-solid-three/test/fixtures/getter/catalog.ts b/packages/vite-plugin-solid-three/test/fixtures/getter/catalog.ts new file mode 100644 index 00000000..20248aee --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/getter/catalog.ts @@ -0,0 +1,8 @@ +import * as THREE from "three" +import { createT } from "solid-three" + +class Custom { + getterMarker() {} +} + +export const T = createT({ ...THREE, get Special() { return Custom } }) diff --git a/packages/vite-plugin-solid-three/test/fixtures/getter/scene.tsx b/packages/vite-plugin-solid-three/test/fixtures/getter/scene.tsx new file mode 100644 index 00000000..9a4eb13e --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/getter/scene.tsx @@ -0,0 +1,4 @@ +import { T } from "./catalog.ts" +export function Scene() { + return +} diff --git a/packages/vite-plugin-solid-three/test/fixtures/getter/tsconfig.json b/packages/vite-plugin-solid-three/test/fixtures/getter/tsconfig.json new file mode 100644 index 00000000..0e3e4e8d --- /dev/null +++ b/packages/vite-plugin-solid-three/test/fixtures/getter/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "solid-js", "module": "ESNext", "moduleResolution": "bundler", "target": "ESNext", "allowImportingTsExtensions": true, "strict": true, "skipLibCheck": true } } diff --git a/packages/vite-plugin-solid-three/test/providers.test.ts b/packages/vite-plugin-solid-three/test/providers.test.ts index 7841ca74..9718497b 100644 --- a/packages/vite-plugin-solid-three/test/providers.test.ts +++ b/packages/vite-plugin-solid-three/test/providers.test.ts @@ -13,8 +13,12 @@ describe("providers", () => { expect([...keyUniverse(sources, nsKeys)].sort()).toEqual(["Box", "Group", "Mesh"]) }) it("last writer wins: Mesh -> Custom, Group -> THREE.Group", () => { - expect(providerFor("Mesh", sources, nsKeys)).toBe("Custom") - expect(providerFor("Group", sources, nsKeys)).toBe("THREE.Group") + expect(providerFor("Mesh", sources, nsKeys)).toEqual({ text: "Custom", verbatim: false }) + expect(providerFor("Group", sources, nsKeys)).toEqual({ text: "THREE.Group", verbatim: false }) + }) + it("marks a verbatim getter/method provider", () => { + const withGetter: CatalogueSource[] = [{ kind: "entry", key: "Foo", valueText: "get Foo(){ return A }", verbatim: true }] + expect(providerFor("Foo", withGetter, nsKeys)).toEqual({ text: "get Foo(){ return A }", verbatim: true }) }) it("returns undefined for a key no source provides", () => { expect(providerFor("Nope", sources, nsKeys)).toBeUndefined() From 90c7719e092bdbba0e5761b2d7ddc30a91b5fbbb Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 18:22:09 +0200 Subject: [PATCH 19/21] =?UTF-8?q?docs(spec):=20post-implementation=20notes?= =?UTF-8?q?=20=E2=80=94=20build-cost=20trilemma,=20cheaper=20measure=20pas?= =?UTF-8?q?s,=20unplugin=20door=20reopened?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-01-createt-narrowing-design.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md b/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md index e8610ac4..de446c7c 100644 --- a/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md +++ b/docs/superpowers/specs/2026-06-01-createt-narrowing-design.md @@ -109,16 +109,20 @@ Gone: ts-morph whole-project `Program`, `findReferencesAsNodes` symbol following ## Non-goals - No change to the `createT` runtime API or its dynamism. Dynamic catalogues stay fully dynamic (we bail). -- No cross-bundler support. Vite only. (The mechanism is Rollup-native, so a Rollup plugin is a later possibility, but not a goal.) +- No cross-bundler support *for now* — Vite only. Unlike the old design, cross-bundler is no longer architecturally blocked (see "unplugin / cross-bundler" under Open questions); it's deferred, not impossible. - No dev-mode narrowing — dev keeps the runtime proxy. +## Resolved during implementation + +- **Orchestration:** proven. A single Vite plugin spawns one nested `build()` from `buildStart` (re-entrancy via a module-level `measuring` flag), captures `renderedExports` in the measure build's `generateBundle` into module-level maps, and the real build's `transform` reads them. Single-instance config-replay (`{ ...userConfig, configFile: false, build: { write: false } }`) worked; the two-plugin fallback was not needed. +- **`createT` argument parser:** settled on `ts.createSourceFile` (single-file, syntactic — no Program, no type-checking). +- **Multiple `createT` calls / catalogues:** handled by a per-call `siteIndex`; one scaffold + used-set per call. + ## Open questions -- **Orchestration details:** nested `build()` ergonomics, re-entrancy flag mechanism, and threading `renderedExports` from sub-build to real build. The main thing to prototype. -- **Multiple `createT` calls / multiple catalogues:** one scaffold + used-set per call; confirm per-call isolation in the measurement build. -- **`createT` argument parser:** `this.parse` vs a bundled parser; must handle TS/JSX at `enforce: "pre"`. -- **2× build cost:** acceptable for production? Possible mitigations (lighter measure build: no minify, skip non-essential plugins) — but the measure graph must match the real graph, so prune carefully. -- **Single-pass fast path (deferred):** if the wrapped-namespace form were ever an acceptable shipped runtime, `createT(THREE)` could skip pass 2 entirely. Rejected for now (debugging clarity; keep `createT`), but the mechanism supports it. +- **The build-cost trilemma.** You can have any two of: single pass · `createT` kept in the output · exact bundler-measured narrowing. We chose the latter two, which costs a second build. Single-pass + `createT` is only possible by measuring the used-set ourselves (the deleted ts-morph/static-analysis approach, over-approximate). Single-pass + exact is only possible by shipping the wrapped-namespace form instead of `createT` (dropped for debugging clarity, but the mechanism supports it — worth revisiting if build time outweighs the keep-`createT` preference). +- **Cheaper measurement pass (the practical lever).** Pass 1 only needs the module graph + tree-shaking to read `renderedExports`; it doesn't need minification, sourcemaps, or output writing (already `write: false`). Trimming toward "build until tree-shaking, read, abort" moves the cost from ~2× toward ~1.4–1.6×. The measure graph must still match the real graph, so prune only output-stage work, never modules/plugins that affect the graph. Plus: cache the measured set across rebuilds (watch mode) when the reachable graph is unchanged. +- **unplugin / cross-bundler — the door is reopened.** unplugin was rejected originally because cross-bundler *soundness* required reconstructing each bundler's alias/resolution, which only Vite could do faithfully. This design reconstructs no resolution — the bundler does its own and tree-shakes soundly — so cross-bundler soundness is now *achievable*. The bundler-agnostic core (`analyze`/`providers`/`rewrite`/`scaffold`, plus `transform`/`resolveId`/`load`) already maps to unplugin universal hooks. Two seams remain bundler-specific and would need a per-bundler adapter + spike: (1) spawning the nested measurement build (`rollup.rollup()` / `webpack()` / `esbuild.build()` / `rspack()`), and (2) reading which exports survived tree-shaking — Rollup/Vite `renderedExports` (proven), webpack/rspack `usedExports` in stats (likely), esbuild metafile (coarser — uncertain). Recommendation: stay Vite-only for now (the consumer base is ~all Vite), but isolate those two seams behind a small interface in `index.ts` so a future unplugin port is additive adapters, not a rearchitecture. ## Test strategy From 53e9336a62ff9c488d7ea83561f96143995fe295 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 19:08:49 +0200 Subject: [PATCH 20/21] fix(vps3): default-export Heavy fixture so lazy() typechecks (root tsc) --- .../vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx b/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx index 76ef7c8a..9aa4beb0 100644 --- a/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx +++ b/packages/vite-plugin-solid-three/test/fixtures/code-split/Heavy.tsx @@ -1,4 +1,5 @@ import { T } from "./catalog.ts" -export function Heavy() { +// default export so `lazy(() => import("./Heavy.tsx"))` typechecks +export default function Heavy() { return } From 3144780d54c0b62f8bd89564bb9d6be942ce2761 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Mon, 1 Jun 2026 19:10:16 +0200 Subject: [PATCH 21/21] chore: stop tracking docs/superpowers/plans (internal process artifacts); gitignore them --- .gitignore | 3 +- .../2026-05-26-port-next-to-next-solid-2.md | 1572 ----------------- docs/superpowers/plans/2026-05-26-tutorial.md | 1199 ------------- .../plans/2026-05-27-hero-gallery.md | 905 ---------- .../plans/2026-05-27-hero-splash-repl.md | 1074 ----------- .../plans/2026-05-27-tetris-chapter.md | 1119 ------------ .../plans/2026-05-27-tutorial-lazy-editor.md | 949 ---------- .../plans/2026-05-29-duck-walk-hero.md | 406 ----- .../2026-06-01-vite-plugin-solid-three.md | 1168 ------------ 9 files changed, 2 insertions(+), 8393 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-26-port-next-to-next-solid-2.md delete mode 100644 docs/superpowers/plans/2026-05-26-tutorial.md delete mode 100644 docs/superpowers/plans/2026-05-27-hero-gallery.md delete mode 100644 docs/superpowers/plans/2026-05-27-hero-splash-repl.md delete mode 100644 docs/superpowers/plans/2026-05-27-tetris-chapter.md delete mode 100644 docs/superpowers/plans/2026-05-27-tutorial-lazy-editor.md delete mode 100644 docs/superpowers/plans/2026-05-29-duck-walk-hero.md delete mode 100644 docs/superpowers/plans/2026-06-01-vite-plugin-solid-three.md diff --git a/.gitignore b/.gitignore index 02f86b9a..d3c9d114 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ tests/**/__screenshots__/ site/.output site/.nitro site/.solid-start -site/.vinxi \ No newline at end of file +site/.vinxi +docs/superpowers/plans/ \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-26-port-next-to-next-solid-2.md b/docs/superpowers/plans/2026-05-26-port-next-to-next-solid-2.md deleted file mode 100644 index 4ff98e58..00000000 --- a/docs/superpowers/plans/2026-05-26-port-next-to-next-solid-2.md +++ /dev/null @@ -1,1572 +0,0 @@ -# Port `next` features to `next-solid-2`: implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bring `next-solid-2` to feature parity with `next` — WebGPU/RendererLike, construction firewall, gl tuple form, duck-typed attach, useLoader+Suspense fix, Resource attach fix, regression tests — packaged as one PR with 14 logical commits. - -**Architecture:** Branch `next-solid-2-port` in worktree `/Users/bigmistqke/Documents/GitHub/solid-three-port`, off `next-solid-2`. Port commits in dependency order. Mechanical diffs applied where safe (`types.ts`, `utils.ts`, `props.ts`, `components.tsx`, parts of `canvas.tsx`). Fresh re-implementation against `next-solid-2`'s `setContext`/`@solidjs/signals` idiom for `create-three.tsx`. After each commit: `tsc --noEmit` + `vitest run` must pass before moving on. - -**Tech Stack:** Solid 2.x beta, `@solidjs/signals`, three ^0.181, TypeScript, vitest, jsdom. - -**Reference spec:** `docs/superpowers/specs/2026-05-26-port-next-to-next-solid-2-design.md` (read first if context is missing — it has the architecture delta, per-feature intent, and risk register). - -## Solid 1.x → Solid 2.x API delta (audit during Task 6) - -The `fixes` branch uses Solid 1.x. `next-solid-2` targets Solid 2.x beta.10. Several primitives referenced in the source-of-truth code (and in this plan's earlier drafts) do NOT exist in Solid 2.x. Audited against `node_modules/solid-js/types/` and `node_modules/@solidjs/signals/dist/types/`: - -| Solid 1.x | Solid 2.x | Affected tasks | -|---|---|---| -| `mergeProps` | `merge` | T7 (already noted as risk #1) | -| `splitProps(props, [keys])` | `omit(props, ...keys)` (returns rest only) | T12 already handled by existing solid-2 Resource | -| `createComputed` | `createRenderEffect` | next-solid-2 already rewrote `useSceneGraph` accordingly; T10's `isMaterial`/etc swap doesn't touch this | -| `createResource` | `createSignal(() => Promise)` + `isPending(signal)` | **T6 corrected below** | -| `Suspense` | `Loading` | **T14 test imports** | -| `ErrorBoundary` | `Errored` | not referenced | -| `JSX` from `solid-js` | `JSX` from `@solidjs/web` | next-solid-2 already uses correct import | - -Quick verification: `grep -E "^export" node_modules/solid-js/types/index.d.ts` confirms the current export set. - -**Working directory:** All commands run from `/Users/bigmistqke/Documents/GitHub/solid-three-port` unless noted. - ---- - -## Preflight - -### Task 0: Verify worktree state - -**Files:** none - -- [ ] **Step 1: Confirm worktree and branch** - -```bash -cd /Users/bigmistqke/Documents/GitHub/solid-three-port -git status -git log --oneline -1 -``` - -Expected: clean tree on `next-solid-2-port`, HEAD at `99a428a fix: fix type errors` (or whatever `next-solid-2` tip was when the worktree was created). - -- [ ] **Step 2: Confirm `fixes` is reachable as source-of-truth** - -```bash -git show fixes:src/types.ts | head -5 -``` - -Expected: prints first 5 lines of `src/types.ts` from the `fixes` branch. - -- [ ] **Step 3: Establish baseline test pass** - -```bash -pnpm install -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: both pass. If they don't, STOP — the port is starting from a broken base and needs investigation before continuing. - ---- - -## Task 1: Bump three + @types/three to ^0.181 - -Source commit: `33d5a78`. Strategy: diff-port. - -**Files:** -- Modify: `package.json` -- Modify: `pnpm-lock.yaml` (regenerated) -- Modify: `tests/web/__snapshots__/canvas.test.tsx.snap` (canvas `data-engine` attribute changes from `three.js r164` to `three.js r181`) -- Modify: `tests/core/renderer.test.tsx` (color-space test fixture: `gl.outputColorSpace = "test"` → use a valid-but-wrong value) - -- [ ] **Step 1: Capture the fixes-branch versions of the relevant lines** - -```bash -git show fixes:package.json | grep -E '"three"|@types/three' -``` - -Expected output (paste exact versions into next step): -``` - "@types/three": "^0.181.0", - "three": "^0.181.0", -``` - -- [ ] **Step 2: Update package.json** - -```bash -grep -n "three" package.json -``` - -Edit `package.json` to set `"three"` and `"@types/three"` to `^0.181.0` (use the exact versions from Step 1). Leave all other deps alone. - -- [ ] **Step 3: Regenerate the lockfile** - -```bash -pnpm install -``` - -Expected: `pnpm-lock.yaml` updates; no errors. - -- [ ] **Step 4: Port the snapshot delta** - -```bash -git diff next-solid-2-port..fixes -- tests/web/__snapshots__/canvas.test.tsx.snap -``` - -Apply only the `data-engine="three.js r164"` → `data-engine="three.js r181"` change. Other snapshot lines may differ for solid-2 reasons — leave those alone. - -- [ ] **Step 5: Port the renderer.test.tsx color-space fixture** - -```bash -git diff next-solid-2-port..fixes -- tests/core/renderer.test.tsx | grep -A2 -B2 "outputColorSpace" -``` - -Find the test that assigns `gl.outputColorSpace = "test"` (the sentinel that three 0.181 crashes on). Change to `gl.outputColorSpace = THREE.LinearSRGBColorSpace as any` (or matching the diff). This is a 1-line change. - -- [ ] **Step 6: Type-check and run tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: both pass. If `tsc` reports new errors from `@types/three@0.181`, fix them inline (typically a `Camera` vs `PerspectiveCamera` narrowing or an `any` cast). - -- [ ] **Step 7: Commit** - -```bash -git add package.json pnpm-lock.yaml tests/web/__snapshots__/canvas.test.tsx.snap tests/core/renderer.test.tsx -git commit -m "chore(deps): bump three and @types/three to ^0.181" -``` - ---- - -## Task 2: Introduce RendererLike + Renderer union + Register augmentation - -Source commits: `497eb60`, `2b678b3`, `79db70b`, `7cb2ecb`, `0bdaa7f` (+ `03fd4a6` DPR optionality on `RendererLike`). Strategy: diff-port. Pure type-level — no runtime behavior change yet. - -**Files:** -- Modify: `src/types.ts` -- Modify: `src/utils.ts` (1-line `hasColorSpace` generic widening) - -- [ ] **Step 1: Read the target shape** - -```bash -git show fixes:src/types.ts | sed -n '/RendererLike/,/^export/p' | head -80 -``` - -This shows the final `RendererLike`, `Renderer`, `Register`, `ResolvedRenderer` declarations and their imports. - -- [ ] **Step 2: Read the current `src/types.ts` to find insertion point** - -```bash -sed -n '1,40p' src/types.ts -grep -n "WebGLRenderer\|Renderer" src/types.ts | head -10 -``` - -- [ ] **Step 3: Add `WebGPURenderer` import** - -Add to the three imports at the top: - -```ts -import type { WebGPURenderer } from "three/webgpu" -``` - -(Some bundlers/types resolutions may need `import type` from `"three/webgpu"` only — `three` ^0.181 ships this subpath. If TypeScript can't find it, fall back to `import type { WebGPURenderer } from "three"`.) - -- [ ] **Step 4: Add the type declarations** - -Insert near the existing renderer types in `src/types.ts`: - -```ts -/** - * Structural contract for a renderer solid-three can drive. Covers what - * solid-three actually calls (render, setSize, domElement) plus optional - * fields that may or may not exist (xr, shadowMap, init, hasInitialized, - * setPixelRatio, getPixelRatio). - */ -export interface RendererLike { - render(scene: any, camera: any): void - setSize(width: number, height: number, updateStyle?: boolean): void - domElement: Element - setPixelRatio?(value: number): void - getPixelRatio?(): number - xr?: WebGLRenderer["xr"] | WebGPURenderer["xr"] - shadowMap?: WebGLRenderer["shadowMap"] | WebGPURenderer["shadowMap"] - init?(): Promise - hasInitialized?(): boolean -} - -export type Renderer = WebGLRenderer | WebGPURenderer | RendererLike - -/** - * Module-augmentation point. Users declare their renderer choice in a - * project-local .d.ts to type-narrow `useThree().gl` and `` - * project-wide. - * - * declare module "solid-three" { - * interface Register { renderer: WebGPURenderer } - * } - */ -export interface Register {} -export type ResolvedRenderer = Register extends { renderer: infer R } ? R : Renderer -``` - -- [ ] **Step 5: Widen `hasColorSpace` in utils.ts to accept `RendererLike`** - -```bash -grep -n "hasColorSpace" src/utils.ts -``` - -Find the `hasColorSpace` generic. Change its constraint from `Renderer` (or whatever WebGL-specific bound it has) to `RendererLike`: - -```ts -export const hasColorSpace = (value: T): value is T & { outputColorSpace: string } => - "outputColorSpace" in value -``` - -(Exact existing signature may differ slightly — preserve the function body, just widen the generic constraint and the import.) - -Add `RendererLike` to the `types.ts` import block if not already there. - -- [ ] **Step 6: Type-check** - -```bash -pnpm exec tsc --noEmit -``` - -Expected: passes. If errors surface in `canvas.tsx` because `gl` is now ambiguously typed — leave them; Task 3 fixes the canvas `gl` prop type. - -If errors surface elsewhere (e.g. `WebGPURenderer` import unresolved), fix the import path. Try `"three/webgpu"` first, fall back to `"three"`. - -- [ ] **Step 7: Run tests** - -```bash -pnpm exec vitest run -``` - -Expected: passes (no runtime change yet). - -- [ ] **Step 8: Commit** - -```bash -git add src/types.ts src/utils.ts -git commit -m "feat(types): introduce RendererLike + Renderer union + Register augmentation" -``` - ---- - -## Task 3: Canvas runtime — accept RendererLike, await init, structural color mgmt, DOM-renderer DPR - -Source commits: `14489dd`, `dcbab71`, `5338b52`, `03fd4a6`, `7bade2e`, `f47b7c2`. Strategy: **fresh** for `create-three.tsx` (must integrate with next-solid-2's `setContext`-style file); diff for `canvas.tsx` and `types.ts`-side typing. - -**Files:** -- Modify: `src/canvas.tsx` -- Modify: `src/create-three.tsx` - -- [ ] **Step 1: Read the fixes-branch Canvas gl prop type** - -```bash -git show fixes:src/canvas.tsx | sed -n '/CanvasProps/,/^}/p' | head -40 -``` - -The target shape for `gl`: - -```ts -gl?: - | (WebGLRenderer extends ResolvedRenderer - ? Partial> - : never) - | ((canvas: HTMLCanvasElement) => ResolvedRenderer) - | ResolvedRenderer -``` - -(Tuple form is added in Task 9. Keep this Task 3's signature without the tuple to keep diffs reviewable.) - -- [ ] **Step 2: Update `CanvasProps` in `src/canvas.tsx`** - -Replace the existing `gl` field with the union above. Add `ResolvedRenderer` to the types.ts import. - -- [ ] **Step 3: Read the fixes-branch gl memo in create-three.tsx** - -```bash -git show fixes:src/create-three.tsx | sed -n '/const gl =/,/^ })/p' | head -60 -``` - -Note the branch order: instance → factory → default WebGLRenderer. - -- [ ] **Step 4: Read the current create-three.tsx gl memo for context** - -```bash -grep -n "const gl\|createMemo.*gl\|new WebGLRenderer\|props.gl" src/create-three.tsx | head -20 -``` - -Read ~30 lines around the gl memo to understand next-solid-2's structure (look for `setContext`, the `createMemo` ordering, debug calls). - -- [ ] **Step 5: Rewrite the gl memo using solid-2 idiom** - -Target behavior (do not copy verbatim from fixes — adapt to whatever pattern the surrounding create-three uses; preserve any `createDebug` calls already in place): - -```ts -const gl = createMemo(() => { - const propsGl = props.gl - // Instance check first — recognise any RendererLike (incl. WebGPURenderer) - // regardless of class, otherwise an `instanceof WebGLRenderer` would skip it. - if (propsGl && typeof propsGl === "object" && !Array.isArray(propsGl) - && typeof (propsGl as Renderer).render === "function" - && typeof (propsGl as Renderer).setSize === "function") { - return propsGl as Renderer - } - if (typeof propsGl === "function") { - return autodispose(propsGl(canvas)) - } - const renderer = autodispose(new WebGLRenderer({ canvas, antialias: true, alpha: true })) - // Apply props from the config-object branch (only reachable when WebGLRenderer - // extends ResolvedRenderer, i.e. Register is empty or registers WebGLRenderer). - if (propsGl && typeof propsGl === "object" && !Array.isArray(propsGl)) { - useProps(renderer, propsGl as Partial>) - } - return renderer -}) -``` - -(`autodispose` and `useProps` are already in next-solid-2's utils/props. The detection inline here will be replaced by `isRenderer()` in Task 4 — keeping it inline now keeps this commit focused on the runtime gate.) - -- [ ] **Step 6: Add async init await before first render** - -Find where `render()` is defined in create-three.tsx. Add a closure flag: - -```ts -let glInitialized = true -``` - -Move it next to the existing `pendingRenderRequest`. Inside `render()`, add at the top: - -```ts -if (!glInitialized) return -``` - -Add an effect that awaits init when the renderer swaps. Place it after the `gl` memo: - -```ts -createRenderEffect(() => { - const renderer = gl() - glInitialized = false - let cancelled = false - onCleanup(() => { cancelled = true }) - - const initFn = (renderer as RendererLike).init - const hasInitialized = (renderer as RendererLike).hasInitialized - const alreadyInitialized = hasInitialized?.call(renderer) === true - - if (!initFn || alreadyInitialized) { - glInitialized = true - return - } - - // Pre-size the canvas backing buffer so WebGPU's depth attachment matches. - const rect = canvas.getBoundingClientRect() - const ratio = globalThis.devicePixelRatio || 1 - if (rect.width > 0 && rect.height > 0) { - canvas.width = rect.width * ratio - canvas.height = rect.height * ratio - } - - initFn.call(renderer).then(() => { - if (!cancelled) glInitialized = true - }) -}) -``` - -(This async-effect form is intentional for Task 3 — Task 6 refactors it to `createResource`. Keeping it as-is keeps Task 3 small.) - -- [ ] **Step 7: Switch color-mgmt and tone-mapping gates from `instanceof WebGLRenderer` to structural `in`** - -Find the existing color-mgmt effect (`outputColorSpace`, `toneMapping`). Replace: - -```ts -if (gl() instanceof WebGLRenderer) { ... } -``` - -with two separate structural gates: - -```ts -createRenderEffect(() => { - const renderer = gl() - if (!("outputColorSpace" in renderer)) return - ;(renderer as any).outputColorSpace = props.linear ? LinearSRGBColorSpace : SRGBColorSpace -}) - -createRenderEffect(() => { - const renderer = gl() - if (!("toneMapping" in renderer)) return - ;(renderer as any).toneMapping = props.flat ? NoToneMapping : ACESFilmicToneMapping -}) -``` - -Remove any `outputEncoding` setter (legacy r152 — three 0.181 doesn't need it). - -- [ ] **Step 8: Make `setPixelRatio` optional + default `dpr` to 1** - -Find the resize observer / size effect that calls `gl().setPixelRatio(...)`. Replace with optional call: - -```ts -renderer.setPixelRatio?.(globalThis.devicePixelRatio || 1) -``` - -Find the `dpr` getter on `context` (likely a `get dpr()` in the `Context` object). Replace with: - -```ts -get dpr() { - return gl().getPixelRatio?.() ?? 1 -} -``` - -- [ ] **Step 9: Type-check** - -```bash -pnpm exec tsc --noEmit -``` - -Expected: passes. If `propsGl` flow narrows incorrectly, add `as Renderer` casts at the specific call sites — don't relax the types broadly. - -- [ ] **Step 10: Run tests** - -```bash -pnpm exec vitest run -``` - -Expected: existing tests pass. (Renderer-union-specific regression tests come in Task 14.) - -- [ ] **Step 11: Manual sanity check (optional but recommended)** - -In `next-solid-2-port`, start the playground dev server: - -```bash -pnpm dev -``` - -Open a basic WebGL example. Confirm the scene renders and no console errors fire. Stop server. - -- [ ] **Step 12: Commit** - -```bash -git add src/canvas.tsx src/create-three.tsx -git commit -m "feat(canvas): accept RendererLike, await init, structural color mgmt, DOM-renderer DPR" -``` - ---- - -## Task 4: utils helpers + duck-typed manager narrows in create-three - -Source commits: `da37a66` (manager narrows + getPendingInit), parts of `3c488ce` (isRenderer relocation — already inline in Task 3, but now formalised). Strategy: diff (utils) + fresh (create-three). - -**Files:** -- Modify: `src/utils.ts` -- Modify: `src/create-three.tsx` - -- [ ] **Step 1: Read the target helpers from fixes** - -```bash -git show fixes:src/utils.ts | sed -n '/isRenderer\|isWebXRManager\|isWebGLShadowMap\|getPendingInit/,/^}/p' -``` - -Target definitions (paste straight in — they're self-contained): - -```ts -/** - * Returns true when `value` is an already-built renderer instance. - */ -export function isRenderer(value: unknown): value is Renderer { - return ( - typeof value === "object" && - value !== null && - typeof (value as Renderer).render === "function" && - typeof (value as Renderer).setSize === "function" - ) -} - -/** - * Duck-typed narrow to `WebXRManager`. `setAnimationLoop` is the discriminator - * — three's WebGPU `XRManager` doesn't expose it. - */ -export function isWebXRManager(value: unknown): value is WebXRManager { - return !!value && typeof (value as { setAnimationLoop?: unknown }).setAnimationLoop === "function" -} - -/** - * Duck-typed narrow to `WebGLShadowMap`. `needsUpdate` is the discriminator - * — WebGPURenderer's `shadowMap` is `{ enabled, type }` without it. - */ -export function isWebGLShadowMap(value: unknown): value is WebGLShadowMap { - return !!value && "needsUpdate" in (value as object) -} - -/** - * Returns the renderer's `init()` if it both exists and hasn't been called yet, - * else `undefined`. Used to await async setup (e.g. WebGPURenderer.init) before - * the first render. - */ -export function getPendingInit(renderer: Renderer): (() => Promise) | undefined { - const r = renderer as RendererLike - if (typeof r.init !== "function") return undefined - if (typeof r.hasInitialized === "function" && r.hasInitialized()) return undefined - return () => r.init!.call(r) -} -``` - -- [ ] **Step 2: Add imports to `src/utils.ts`** - -Add to the existing three imports: - -```ts -import type { WebGLShadowMap, WebXRManager } from "three" -``` - -Add `Renderer` and `RendererLike` to the `./types.ts` import (likely already there from Task 2). - -- [ ] **Step 3: Insert the helpers** - -Place them alongside the existing `is*` family (after `isVector3` or wherever the cluster lives). - -- [ ] **Step 4: Replace the inline detection in create-three.tsx gl memo with isRenderer** - -In the `gl` memo from Task 3, replace the verbose inline check: - -```ts -if (propsGl && typeof propsGl === "object" && !Array.isArray(propsGl) - && typeof (propsGl as Renderer).render === "function" - && typeof (propsGl as Renderer).setSize === "function") { - return propsGl as Renderer -} -``` - -with: - -```ts -if (isRenderer(propsGl)) { - return propsGl -} -``` - -Add `isRenderer` to the utils import. - -- [ ] **Step 5: Gate XR wiring on isWebXRManager** - -Find the XR section (`handleSessionChange`, `xr.connect`, `xr.disconnect`, the effect that wires `sessionstart`/`sessionend` listeners). Gate each with: - -```ts -function handleSessionChange() { - const xrManager = context.gl.xr - if (!isWebXRManager(xrManager)) return - xrManager.enabled = xrManager.isPresenting - xrManager.setAnimationLoop(xrManager.isPresenting ? handleXRFrame : null) -} - -const xr = { - connect() { - const xrManager = context.gl.xr - if (!isWebXRManager(xrManager)) return - xrManager.addEventListener("sessionstart", handleSessionChange) - xrManager.addEventListener("sessionend", handleSessionChange) - }, - disconnect() { - const xrManager = context.gl.xr - if (!isWebXRManager(xrManager)) return - xrManager.removeEventListener("sessionstart", handleSessionChange) - xrManager.removeEventListener("sessionend", handleSessionChange) - }, -} -``` - -The connect-effect call that wires session listeners likewise gates on `isWebXRManager(context.gl.xr)`. - -(The `console.warn` for no-op calls is added in Task 5 — leave silent here.) - -- [ ] **Step 6: Adapt shadow-map effect** - -Find the shadow-map effect. Change from a single `instanceof WebGLRenderer` guard to per-field gating: - -```ts -createRenderEffect(() => { - const shadowMap = gl().shadowMap - if (!shadowMap) return - - // Common fields any renderer with shadowMap supports. - if (props.shadows !== undefined) { - shadowMap.enabled = !!props.shadows - shadowMap.type = /* existing mapping from props.shadows to PCFShadowMap/etc. */ - } - - // WebGL-only: needsUpdate signals the renderer to re-bake. - if (isWebGLShadowMap(shadowMap)) { - shadowMap.needsUpdate = true - } -}) -``` - -(Preserve the existing `shadows` → shadow-map-type mapping logic; only gate the `needsUpdate` write.) - -- [ ] **Step 7: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 8: Commit** - -```bash -git add src/utils.ts src/create-three.tsx -git commit -m "feat(utils): isRenderer + duck-typed manager narrows + getPendingInit" -``` - ---- - -## Task 5: Warn on no-op xr.connect/xr.disconnect - -Source commit: `fa97fd6`. Strategy: fresh (1-line change inside Task 4's gated handlers). - -**Files:** Modify `src/create-three.tsx` - -- [ ] **Step 1: Add the warn helper** - -Above the `xr` object, add: - -```ts -function warnNonXR(method: string) { - console.warn( - `solid-three: ${method} is a no-op — the active renderer has no WebXRManager-shaped \`xr\` manager. Pass a WebGLRenderer (or a WebGPURenderer with three's XR layer) to enable XR.`, - ) -} -``` - -- [ ] **Step 2: Wire the warning into the gated guards** - -Change `xr.connect` / `xr.disconnect` from silent return to warn-then-return: - -```ts -connect() { - const xrManager = context.gl.xr - if (!isWebXRManager(xrManager)) return warnNonXR("xr.connect()") - /* …existing wiring… */ -}, -disconnect() { - const xrManager = context.gl.xr - if (!isWebXRManager(xrManager)) return warnNonXR("xr.disconnect()") - /* …existing wiring… */ -}, -``` - -Leave `handleSessionChange` silent (it's an internal callback, not user-facing). - -- [ ] **Step 3: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 4: Commit** - -```bash -git add src/create-three.tsx -git commit -m "feat(xr): warn when context.xr.connect/disconnect is a no-op" -``` - ---- - -## Task 6: Use createSignal(async fn) for renderer init - -**Plan correction (2026-05-26):** Solid 2.x beta.10 does NOT export `createResource`. The idiomatic replacement is `createSignal(() => Promise)` (an async derived signal). The signal's read access surfaces as `NotReadyError` or via `isPending(signal)` while the Promise is pending. Source commit on `fixes`: `addab6f` (uses 1.x `createResource`). Strategy: fresh (replaces Task 3's async-createEffect form). - -**Files:** Modify `src/create-three.tsx` - -- [ ] **Step 1: Locate the existing async-effect init block from Task 3** - -```bash -grep -n "glInitialized\|cancelled" src/create-three.tsx -``` - -- [ ] **Step 2: Add `isPending` to the solid-js import** - -```ts -import { /* existing */ createSignal, isPending, /* … */ } from "solid-js" -``` - -`createSignal` may already be imported; `isPending` is the new addition. NOT `createResource` — that doesn't exist in Solid 2.x. - -- [ ] **Step 3: Replace the async-effect block with `createSignal(async fn)`** - -Remove the `let glInitialized = true` declaration and the `createRenderEffect(() => { ...init... })` block. - -Insert after the `gl` memo: - -```ts -const [rendererReady] = createSignal(async () => { - const renderer = gl() - const init = getPendingInit(renderer) - if (!init) return true - // Pre-size the canvas before init so WebGPU's depth attachment matches - // the container — workaround for the 300×150 default backing buffer - // (see pmndrs/react-three-fiber#3651). - const rect = canvas.getBoundingClientRect() - const ratio = globalThis.devicePixelRatio || 1 - if (rect.width > 0 && rect.height > 0) { - canvas.width = rect.width * ratio - canvas.height = rect.height * ratio - } - await init() - return true -}) -``` - -The signal's `ComputeFunction` re-runs when its tracked sources (`gl()`) change. When the fn returns a Promise, the signal's value becomes pending until the Promise resolves. Solid 2.x handles cancellation automatically when sources change. - -Add `getPendingInit` to the utils import. - -- [ ] **Step 4: Update the render() gate** - -Replace `if (!glInitialized) return` with: - -```ts -if (isPending(rendererReady)) return -``` - -`render()` is called from `requestAnimationFrame`, not inside a tracking scope, so `isPending(rendererReady)` checks the state without subscribing. - -- [ ] **Step 5: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -If `tsc` errors with "Type 'Promise' is not assignable to type 'boolean'" — the `createSignal` type parameter combined with a `Promise` return value is what `ComputeFunction` accepts (`(prev) => Promise | T`). Confirm against `node_modules/@solidjs/signals/dist/types/signals.d.ts` if the inference is fighting you. - -If reading `rendererReady` outside a tracking scope throws `NotReadyError` instead of returning a value, wrap the call: `try { rendererReady() } catch { /* still pending */ }`. Prefer `isPending(rendererReady)` first. - -- [ ] **Step 6: Commit** - -```bash -git add src/create-three.tsx -git commit -m "refactor(create-three): use createSignal(async fn) for renderer init" -``` - ---- - -## Task 7: Fix meta() to preserve getters via `merge` - -Source commits: `00c664d`, `aaeec88`. Strategy: diff (verify Solid 2 `merge` semantics). - -**Files:** Modify `src/utils.ts` - -- [ ] **Step 1: Read the current meta() implementation** - -```bash -grep -n "export function meta\|defineProperties\|\\.\\.\\.augmentation" src/utils.ts -sed -n '/export function meta/,/^}/p' src/utils.ts | head -20 -``` - -- [ ] **Step 2: Read the target from fixes** - -```bash -git show fixes:src/utils.ts | sed -n '/export function meta/,/^}/p' | head -20 -``` - -The fixes version uses `mergeProps`: - -```ts -export function meta(instance: T, augmentation = { props: {} }) { - // ... existing setup that defines `data` (the metadata object) ... - return mergeProps(instance, { [$S3C]: data }) as Meta -} -``` - -`next-solid-2` uses `merge` (the Solid 2 rename). Same intent — getters preserved, no spread. - -- [ ] **Step 3: Verify `merge` preserves getters identically** - -Write a quick standalone smoke test in a scratch file or via `node -e`: - -```bash -node --experimental-vm-modules -e ' -import("solid-js").then(({ merge }) => { - let called = 0 - const augmentation = { get props() { called++; return { name: "test" } } } - const result = merge({ value: 1 }, augmentation) - console.log("after merge, called:", called) // should be 0 - console.log("result.props.name:", result.props.name) // should be "test", called now 1 - console.log("after access, called:", called) -}) -' -``` - -Expected output: -``` -after merge, called: 0 -result.props.name: test -after access, called: 1 -``` - -If `called` is `1` immediately after merge, then `merge` invokes getters at merge-time and **the firewall in Task 8 will leak**. STOP and surface — the spec's risk #1 has materialised. Mitigation: implement `meta` using `Object.defineProperties` instead (the pre-`aaeec88` form): - -```ts -export function meta(instance: T, augmentation = { props: {} }) { - const data = /* existing data object */ - const descriptors: PropertyDescriptorMap = {} - for (const key of Object.keys(augmentation)) { - const desc = Object.getOwnPropertyDescriptor(augmentation, key) - if (desc) descriptors[key] = desc - } - Object.defineProperty(instance, $S3C, { value: data, enumerable: false }) - Object.defineProperties(data, descriptors) - return instance as Meta -} -``` - -- [ ] **Step 4: Update meta() to use the chosen primitive** - -If `merge` passes the smoke test, change `meta()` to use it. If not, use `defineProperties`. - -- [ ] **Step 5: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 6: Commit** - -```bash -git add src/utils.ts -git commit -m "fix(utils): meta() preserves getters without invoking at merge-time" -``` - ---- - -## Task 8: Construction firewall memos - -Source commit: `a31ee8c`. Strategy: fresh. - -**Files:** Modify `src/create-three.tsx` - -- [ ] **Step 1: Read the target firewall structure from fixes** - -```bash -git show fixes:src/create-three.tsx | sed -n '/cameraIsInstance\|sceneIsInstance\|raycasterIsInstance\|glKind\|cameraInput\|sceneInput\|raycasterInput/p' -``` - -- [ ] **Step 2: Locate the existing camera, scene, raycaster, gl memos in create-three** - -```bash -grep -n "createMemo\|const camera\|const scene\|const raycaster" src/create-three.tsx | head -20 -``` - -Read ~30 lines around each to understand the current expression. - -- [ ] **Step 3: Add boolean firewall memos** - -Before the construction memos, add: - -```ts -const cameraIsInstance = createMemo(() => props.camera instanceof Camera) -const orthographicFlag = createMemo(() => Boolean(props.orthographic)) -const sceneIsInstance = createMemo(() => props.scene instanceof Scene) -const raycasterIsInstance = createMemo(() => props.raycaster instanceof Raycaster) -const glKind = createMemo<"factory" | "instance" | "default">(() => { - const value = props.gl - if (isRenderer(value)) return "instance" - if (typeof value === "function") return "factory" - return "default" -}) -``` - -These are === comparable, so a JSX getter call producing the same category doesn't propagate. - -- [ ] **Step 4: Rewrite the camera memo to depend only on firewall booleans** - -```ts -const camera = createMemo(() => { - if (cameraIsInstance()) { - // Read the full prop here — instance identity matters. - return props.camera as Camera - } - return autodispose( - orthographicFlag() - ? new OrthographicCamera() - : new PerspectiveCamera(), - ) -}) -``` - -Then apply config-object props in a separate effect, untracked from the camera memo's deps: - -```ts -createRenderEffect(() => { - const cam = camera() - // Only apply config when the user passed a partial config-object (not an instance). - if (cameraIsInstance()) return - const cfg = untrack(() => props.camera) - if (cfg && typeof cfg === "object") { - useProps(cam, cfg as Partial>) - } -}) -``` - -(`untrack` is already imported in solid-2. The trick is: `cameraIsInstance` is the tracked dep — when it flips false (config-mode), the effect re-runs once; subsequent prop-content changes inside `useProps` are picked up by `useProps`'s own reactivity, not this effect's tracking.) - -- [ ] **Step 5: Apply the same pattern to scene, raycaster, gl** - -Mirror Step 4 for each. The `gl` memo's three branches: - -```ts -const gl = createMemo(() => { - const kind = glKind() - if (kind === "instance") { - return untrack(() => props.gl) as Renderer - } - if (kind === "factory") { - const factory = untrack(() => props.gl) as (canvas: HTMLCanvasElement) => Renderer - return autodispose(factory(canvas)) - } - // default - const renderer = autodispose(new WebGLRenderer({ canvas, antialias: true, alpha: true })) - return renderer -}) - -// Config-object props applied via separate effect (gated to "default" kind only). -createRenderEffect(() => { - if (glKind() !== "default") return - const cfg = untrack(() => props.gl) - if (cfg && typeof cfg === "object" && !Array.isArray(cfg)) { - useProps(gl(), cfg as Partial>) - } -}) -``` - -- [ ] **Step 6: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. (Firewall regression tests come in Task 14.) - -- [ ] **Step 7: Commit** - -```bash -git add src/create-three.tsx -git commit -m "feat(canvas): firewall construction memos against reactive prop content" -``` - ---- - -## Task 9: Accept [ctorArgs, properties] tuple for gl - -Source commit: `a635c3c`. Strategy: diff (canvas type, shallowEqual) + fresh (create-three gl-tuple handling). - -**Files:** -- Modify: `src/canvas.tsx` -- Modify: `src/utils.ts` -- Modify: `src/create-three.tsx` - -- [ ] **Step 1: Add `shallowEqual` to utils.ts** - -```bash -git show fixes:src/utils.ts | sed -n '/shallowEqual/,/^}/p' | head -15 -``` - -Paste: - -```ts -export function shallowEqual(a: any, b: any): boolean { - if (a === b) return true - if (!a || !b || typeof a !== "object" || typeof b !== "object") return false - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (a[key] !== b[key]) return false - } - return true -} -``` - -- [ ] **Step 2: Extend the gl prop type in `src/canvas.tsx`** - -Add the tuple form to the `gl?:` union (inside the same `WebGLRenderer extends ResolvedRenderer ? ... : never` conditional): - -```ts -gl?: - | (WebGLRenderer extends ResolvedRenderer - ? Partial> - | readonly [ - constructorParameters: Partial, - properties: Partial>, - ] - : never) - | ((canvas: HTMLCanvasElement) => ResolvedRenderer) - | ResolvedRenderer -``` - -Add `WebGLRendererParameters` import from `three`. - -- [ ] **Step 3: Update `glKind` to recognise the tuple** - -Inside `glKind` from Task 8: - -```ts -const glKind = createMemo<"factory" | "instance" | "tuple" | "default">(() => { - const value = props.gl - if (isRenderer(value)) return "instance" - if (typeof value === "function") return "factory" - if (Array.isArray(value)) return "tuple" - return "default" -}) -``` - -- [ ] **Step 4: Add a tuple[0] memo with shallowEqual recreation guard** - -```ts -const glConstructorArgs = createMemo>( - () => { - if (glKind() !== "tuple") return {} - return (untrack(() => props.gl) as readonly [Partial, unknown])[0] - }, - { equals: shallowEqual }, -) -``` - -- [ ] **Step 5: Wire the tuple branch into the gl memo** - -```ts -const gl = createMemo(() => { - const kind = glKind() - if (kind === "instance") { - return untrack(() => props.gl) as Renderer - } - if (kind === "factory") { - const factory = untrack(() => props.gl) as (canvas: HTMLCanvasElement) => Renderer - return autodispose(factory(canvas)) - } - if (kind === "tuple") { - // Construction is driven by tuple[0]; shallowEqual prevents recreation on - // shape-equal but fresh-reference inputs (typical JSX getter output). - const ctorArgs = glConstructorArgs() - return autodispose(new WebGLRenderer({ canvas, ...ctorArgs })) - } - // default - return autodispose(new WebGLRenderer({ canvas, antialias: true, alpha: true })) -}) -``` - -- [ ] **Step 6: Apply tuple[1] as reactive properties** - -Extend the config-effect from Task 8 to handle the tuple case: - -```ts -createRenderEffect(() => { - const kind = glKind() - if (kind === "default") { - const cfg = untrack(() => props.gl) - if (cfg && typeof cfg === "object" && !Array.isArray(cfg)) { - useProps(gl(), cfg as Partial>) - } - return - } - if (kind === "tuple") { - const tuple = untrack(() => props.gl) as readonly [unknown, Partial>] - useProps(gl(), tuple[1]) - } -}) -``` - -- [ ] **Step 7: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 8: Commit** - -```bash -git add src/canvas.tsx src/utils.ts src/create-three.tsx -git commit -m "feat(canvas): accept [ctorArgs, properties] tuple for the gl prop" -``` - ---- - -## Task 10: Duck-type Material/Object3D/Fog/BufferGeometry attach checks - -Source commit: `068b8a4`. Strategy: diff. - -**Files:** -- Modify: `src/utils.ts` -- Modify: `src/props.ts` - -- [ ] **Step 1: Add the duck-type helpers to utils.ts** - -```ts -export function isMaterial(value: unknown): value is Material { - return !!value && (value as { isMaterial?: boolean }).isMaterial === true -} -export function isBufferGeometry(value: unknown): value is BufferGeometry { - return !!value && (value as { isBufferGeometry?: boolean }).isBufferGeometry === true -} -export function isFog(value: unknown): value is Fog { - return !!value && (value as { isFog?: boolean }).isFog === true -} -export function isObject3D(value: unknown): value is Object3D { - return !!value && (value as { isObject3D?: boolean }).isObject3D === true -} -``` - -Add `type` imports for `Material`, `BufferGeometry`, `Fog`, `Object3D` from `"three"`. - -- [ ] **Step 2: Update `src/props.ts` applySceneGraph** - -```bash -grep -n "instanceof Material\|instanceof Object3D\|instanceof BufferGeometry\|instanceof Fog" src/props.ts -``` - -Replace each `instanceof` check with the corresponding `is*` helper: - -```ts -// Before: -if (child instanceof Material) attachProp = "material" -else if (child instanceof BufferGeometry) attachProp = "geometry" -else if (child instanceof Fog) attachProp = "fog" - -// After: -if (isMaterial(child)) attachProp = "material" -else if (isBufferGeometry(child)) attachProp = "geometry" -else if (isFog(child)) attachProp = "fog" -``` - -Similarly for the Object3D scene-graph sync block — `parent instanceof Object3D` → `isObject3D(parent)`. - -Change `Material`, `Object3D`, `Fog`, `BufferGeometry` imports in `props.ts` to `import type` (since they're no longer used as runtime values). - -Add `isMaterial`, `isObject3D`, `isFog`, `isBufferGeometry` to the utils.ts import. - -- [ ] **Step 3: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 4: Commit** - -```bash -git add src/utils.ts src/props.ts -git commit -m "fix(props): duck-type Material/Object3D/Fog/BufferGeometry attach checks" -``` - ---- - -## Task 11: Drop merge() when calling useSceneGraph (useLoader+Suspense fix) - -Source commit: `2ee594a`. Strategy: fresh. - -**Files:** Modify `src/create-three.tsx` - -- [ ] **Step 1: Locate the useSceneGraph call** - -```bash -grep -n "useSceneGraph" src/create-three.tsx -``` - -Read ~10 lines around it. Expect to see something like: - -```ts -useSceneGraph( - context.scene, - merge(props, { get children() { return c() } }), -) -``` - -(`merge` is the solid-2 rename of `mergeProps`.) - -- [ ] **Step 2: Remove the merge() wrapper** - -Replace with: - -```ts -useSceneGraph(context.scene, { get children() { return c() } }) -``` - -(`useSceneGraph` reads only `children` and an optional `onUpdate` that Canvas never passes — no reason to merge the full props surface, and the merge's `resolveSources` fallback chain to the user's raw children getter is what caused the Suspense crash.) - -- [ ] **Step 3: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 4: Commit** - -```bash -git add src/create-three.tsx -git commit -m "fix(create-three): drop merge when calling useSceneGraph" -``` - ---- - -## Task 12: Resource attach via meta() - -**Plan correction (2026-05-26):** `next-solid-2` already has its OWN `Resource` implementation in `src/components.tsx` that diverges from the `fixes` version — it uses `omit(props, ...keys)` instead of `splitProps` (which Solid 2.x removed) and wraps children in `` instead of ``. Re-base the port on that existing shape, not on `fixes`'s `splitProps`+`` form. - -Source commit on `fixes`: `4ac1ac7`. Strategy: fresh (against the existing solid-2 Resource). - -**Files:** Modify `src/components.tsx` - -- [ ] **Step 1: Read the current Resource implementation** - -```bash -grep -n "export function Resource\|useLoader\|useProps(resource" src/components.tsx -sed -n '/export function Resource/,/^}/p' src/components.tsx | head -40 -``` - -Expected shape (as of next-solid-2-port HEAD): - -```tsx -export function Resource<...>(props: ResourceProps) { - debugResource("mount", () => ({ /* … */ })) - const rest = omit(props, "base", "cache", "onBeforeLoad", "onLoad", "loader", "url", "children") - const resource = useLoader( - () => props.loader, - () => props.url, - { get base() { … }, get cache() { … }, get onBeforeLoad() { … }, get onLoad() { … } }, - ) - useProps(resource, rest) - return ( - - - {r => props.children?.(r)} - - - ) -} -``` - -- [ ] **Step 2: Insert meta-tagging between `useLoader` and `useProps`** - -The fix is identical in spirit to fixes' `4ac1ac7`: wrap the resource accessor with `meta(value, { props })` so the parent scene graph reads `attach` off the child's meta when this is rendered as JSX. Adapt to next-solid-2's call shape: - -Replace with: - -```tsx -const resource = useLoader(...) - -// Tag the loaded resource with meta so the surrounding scene graph reads -// `attach` (and other meta-driven props) off it when this is rendered as a JSX child. -const tagged = createMemo(() => { - const value = resource() - if (!value || typeof value !== "object") return value - return hasMeta(value) ? value : meta(value as object, { props }) -}) - -useProps(tagged, rest) - -return ( - - - {r => props.children?.(r as Accessor>>)} - - -) -``` - -Add `createMemo` to the solid-js import, and `hasMeta` + `meta` to the utils import (likely already present). Keep the surrounding `` wrapper from the existing solid-2 Resource. - -- [ ] **Step 3: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 4: Commit** - -```bash -git add src/components.tsx -git commit -m "fix(Resource): tag loaded resource with meta so parent attach works" -``` - ---- - -## Task 13: Consolidate isWritable into utils.ts - -Source commit: `3c488ce` (the isWritable half — isRenderer was already moved in Task 4). Strategy: diff. - -**Files:** -- Modify: `src/utils.ts` -- Modify: `src/props.ts` - -- [ ] **Step 1: Locate isWritable in props.ts** - -```bash -grep -n "function isWritable" src/props.ts -``` - -Note its definition (one line: `Object.getOwnPropertyDescriptor(object, propertyName)?.writable`). - -- [ ] **Step 2: Move to utils.ts** - -Delete from `src/props.ts`. Add to `src/utils.ts` (near the other `is*` helpers): - -```ts -export function isWritable(object: object, propertyName: string) { - return Object.getOwnPropertyDescriptor(object, propertyName)?.writable -} -``` - -- [ ] **Step 3: Update the props.ts import** - -Add `isWritable` to the existing utils.ts import in `src/props.ts`. - -- [ ] **Step 4: Type-check + tests** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: passes. - -- [ ] **Step 5: Commit** - -```bash -git add src/utils.ts src/props.ts -git commit -m "refactor(utils): consolidate isWritable into utils.ts" -``` - ---- - -## Task 14: Port regression tests - -Source commits: `37903eb`, `5018338`, `03fd4a6`, `7bade2e`, `7cb2ecb`, `fa97fd6`, `a31ee8c`, `a635c3c`, `5338b52`, `f4d310b`, `2ee594a`, `5d27743`, `b78b048`, `068b8a4`, `dbbe477`. Strategy: diff per file, reconcile against any solid-2 test additions. - -**Plan correction (2026-05-26):** when porting from `fixes`, every test that does `import { Suspense } from "solid-js"` must be swapped to `import { Loading } from "solid-js"` and the `` tags renamed to `` — Solid 2.x removed `Suspense` and renamed the concept to `Loading`. Comments and JSDoc that reference `Suspense` or `mergeProps` may stay or be updated to `Loading`/`merge` for clarity. Applies to: -- `tests/core/use-loader-suspense.test.tsx` (every occurrence) -- `tests/core/hooks.test.tsx` (lines 77/79/139/141 are commented-out `Suspense` blocks in next-solid-2 — uncomment and convert to `Loading`) - -**Files:** -- Modify: `tests/core/renderer.test.tsx` -- Create: `tests/core/use-loader-suspense.test.tsx` (use ``) -- Modify: `tests/core/hooks.test.tsx` (uncomment + convert) -- Create: `tests/core/api-coverage.test.tsx` - -- [ ] **Step 1: renderer.test.tsx — capture the fixes-branch additions** - -```bash -git diff next-solid-2-port..fixes -- tests/core/renderer.test.tsx > /tmp/renderer-test-diff.patch -wc -l /tmp/renderer-test-diff.patch -``` - -Review `/tmp/renderer-test-diff.patch`. Group additions by topic: -- External RendererLike tests (instance, factory, init-await, hasInitialized skip) -- DPR-less renderer test -- domElement structural test -- Foreign Material attach (`f4d310b`) -- xr.connect/disconnect warn assertions (`fa97fd6`) -- Construction firewall tests (5 tests from `a31ee8c`) -- gl tuple tests (5 tests from `a635c3c`) -- getPendingInit unit tests (3 tests from `5018338`) -- XR-stub / shadowMap-stub integration tests (3 from `5018338`) -- Color-space split tests (`5338b52`) - -- [ ] **Step 2: Read the current renderer.test.tsx structure** - -```bash -grep -n "describe\|^ it\|RendererLike\|makeFakeRenderer" tests/core/renderer.test.tsx | head -40 -``` - -- [ ] **Step 3: Apply renderer.test.tsx additions** - -Add the new test blocks. Order them topically — group RendererLike tests together, firewall tests together, etc. Use the `makeFakeRenderer` helper from the fixes version (paste it once near the top of the relevant describe block). - -If next-solid-2 has its own additions that conflict (e.g. test name collision), prefer the solid-2 version and skip the duplicate from fixes. - -- [ ] **Step 4: Run renderer.test.tsx in isolation** - -```bash -pnpm exec vitest run tests/core/renderer.test.tsx -``` - -Expected: all tests pass. If a firewall test fails, suspect Task 7's `merge` behavior — re-check the meta() implementation chosen. - -- [ ] **Step 5: Create use-loader-suspense.test.tsx** - -```bash -git show fixes:tests/core/use-loader-suspense.test.tsx > tests/core/use-loader-suspense.test.tsx -``` - -- [ ] **Step 6: Run use-loader-suspense.test.tsx** - -```bash -pnpm exec vitest run tests/core/use-loader-suspense.test.tsx -``` - -Expected: 2 tests pass (no-fallback + explicit-fallback). - -- [ ] **Step 7: Re-enable useLoader integration tests in hooks.test.tsx** - -```bash -git diff next-solid-2-port..fixes -- tests/core/hooks.test.tsx > /tmp/hooks-test-diff.patch -``` - -Review. Apply the 3 useLoader test additions (single URL, record of URLs, onBeforeLoad). Replace any commented-out variants in the current solid-2 file. - -- [ ] **Step 8: Run hooks.test.tsx** - -```bash -pnpm exec vitest run tests/core/hooks.test.tsx -``` - -Expected: all tests pass including the new 3. - -- [ ] **Step 9: Create api-coverage.test.tsx** - -```bash -git show fixes:tests/core/api-coverage.test.tsx > tests/core/api-coverage.test.tsx -``` - -- [ ] **Step 10: Run api-coverage.test.tsx** - -```bash -pnpm exec vitest run tests/core/api-coverage.test.tsx -``` - -Expected: 11 tests pass. - -- [ ] **Step 11: Full suite final pass** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: all green. - -- [ ] **Step 12: Commit** - -```bash -git add tests/core/renderer.test.tsx tests/core/use-loader-suspense.test.tsx tests/core/hooks.test.tsx tests/core/api-coverage.test.tsx -git commit -m "test: port renderer + use-loader-suspense + hooks + api-coverage regression tests" -``` - ---- - -## Final verification - -After all 14 tasks complete: - -- [ ] **Step 1: Per-file diff sanity** - -```bash -for f in src/types.ts src/canvas.tsx src/utils.ts src/props.ts src/components.tsx; do - echo "=== $f ===" - git diff next-solid-2-port..fixes -- "$f" | wc -l -done -``` - -Expected: each diff is small (under ~50 lines) and isolated to solid-2 idiom differences. Anything larger → revisit the port for that file. - -- [ ] **Step 2: create-three.tsx diff review (expected large)** - -```bash -git diff next-solid-2-port..fixes -- src/create-three.tsx | less -``` - -Review section-by-section. Each logical concern should be present in both versions, expressed in each branch's idiom. Specifically confirm: -- gl memo handles instance / factory / tuple / default -- XR wiring gated on `isWebXRManager`, warns on no-op -- shadow-map effect uses `isWebGLShadowMap` for needsUpdate -- Color-mgmt + tone-mapping use structural `in` checks -- Renderer init uses `createResource` -- Render gate is `rendererReady.state !== "ready"` -- Construction firewall memos in place (booleans + untracked content reads) -- useSceneGraph call has no `merge` wrapper - -- [ ] **Step 3: Type-check + full test suite** - -```bash -pnpm exec tsc --noEmit -pnpm exec vitest run -``` - -Expected: green. - -- [ ] **Step 4: Manual smoke test** - -```bash -pnpm dev -``` - -Open in browser. Confirm: -- A basic WebGL example renders without errors. -- Console has no unexpected warnings (the new `xr.connect()` warn should NOT fire unless explicitly tested). - -Stop the dev server. - -- [ ] **Step 5: Push and open the PR** - -```bash -git push -u bigmistqke next-solid-2-port -gh pr create --repo solidjs-community/solid-three --base next-solid-2 --head bigmistqke:next-solid-2-port --title "feat: port WebGPU/RendererLike + construction firewall + fixes from next" --body "$(cat <<'EOF' -Ports the WebGPU/RendererLike stack, construction firewall, gl tuple form, duck-typed attach, useLoader+Suspense fix, Resource attach fix, and regression tests from `next` (now merged) onto `next-solid-2`. - -See \`docs/superpowers/specs/2026-05-26-port-next-to-next-solid-2-design.md\` for the analysis and design. - -## What's ported -- RendererLike + Renderer union + Register augmentation -- WebGPURenderer init() awaited via createResource -- Construction firewall memos (camera/scene/raycaster/gl no longer recreate on prop content changes) -- gl prop accepts [ctorArgs, properties] tuple -- Duck-typed Material/Object3D/Fog/BufferGeometry attach checks -- useLoader + no-fallback no longer crashes -- propagates to parent -- isRenderer / isWritable consolidated to utils.ts -- Regression tests (~30 new tests across 4 files) - -## What's NOT in this PR (followup) -- WebGPU / CSS3D / SVG playground examples -- README and CONTRIBUTING doc updates -- CI workflow updates (pnpm v11 allowBuilds, Node 22) - -## Test plan -- [x] \`pnpm exec tsc --noEmit\` clean -- [x] \`pnpm exec vitest run\` green -- [ ] Manual: WebGL example renders, no console errors -EOF -)" -``` - ---- - -## Self-review checklist (run before considering plan done) - -- **Spec coverage:** Every F1–F8 group in the spec maps to a task (F1→T2, F2→T3, F3→T4–6, F4→T7–9, F5→T10, F6→T11, F7→T12, F8→T13, F-TESTS→T14). ✓ -- **Placeholder scan:** No "TBD", "implement later", or vague error-handling placeholders. ✓ -- **Type consistency:** `isRenderer` signature reused across T3→T4→T8; `shallowEqual` signature consistent between T9 definition and T9 usage; `createResource` form in T6 matches the `getPendingInit` signature in T4. ✓ -- **Solid-2 trap awareness:** T7 includes the explicit `merge` semantics smoke test with a fallback path. The risk register's #1 (merge-vs-mergeProps for getter preservation) has a concrete mitigation step. ✓ -- **Reversibility:** Each commit is independently revertable; the per-commit `tsc + vitest` gate guarantees the branch stays green throughout. ✓ diff --git a/docs/superpowers/plans/2026-05-26-tutorial.md b/docs/superpowers/plans/2026-05-26-tutorial.md deleted file mode 100644 index bc210ee6..00000000 --- a/docs/superpowers/plans/2026-05-26-tutorial.md +++ /dev/null @@ -1,1199 +0,0 @@ -# solid-three tutorial Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a new `tutorial/` Vite app at the repo root that reads as a continuous, scrollable story teaching solid-three across 17 chapters, with inline editable REPL demos. - -**Architecture:** A sibling project to `playground/`, run as a Vite root (not a separate workspace package). Chapters authored in MDX. A `` component wraps `@bigmistqke/repl` to provide an editable code panel + live canvas per snippet, with a viewport-aware single-pane toggle. `solid-three` and `three` are resolved inside snippets via a custom module resolver that points at the workspace `../src` and the workspace's `three`. - -**Tech Stack:** Solid, Vite, MDX (`@mdx-js/rollup` + `solid-mdx`), `@bigmistqke/repl` (consumed via local relative dependency), TypeScript. - ---- - -## Reference: spec - -The validated spec lives at `docs/superpowers/specs/2026-05-26-tutorial-design.md`. Read it before starting. - -## Reference: existing playground - -`playground/` (App.tsx, vite.config.ts) is the closest existing pattern. The tutorial follows the same shape but with its own root directory and Vite glob import for `.mdx` chapters instead of `.tsx` API pages. - ---- - -## File Structure - -Created in this plan: - -``` -tutorial/ - index.html # Vite entry HTML - vite.config.ts # Vite + MDX + Solid plugin config - tsconfig.json # extends root tsconfig - index.css # global styles - main.tsx # ReactDOM-style mount point for Solid - App.tsx # layout: sidebar + scroll body - sidebar.tsx # nav generated from chapter frontmatter - mdx-components.tsx # component map injected into every MDX - demo.tsx # wrapper around @bigmistqke/repl - demo-stub.tsx # used during early framework tasks - use-active-section.ts # IntersectionObserver-based hash sync - chapter-loader.ts # collects + sorts chapter modules - chapters/ - 01-hello-canvas.mdx - 02-t-proxy.mdx - ... (17 chapters total) - 17-environment-scene.mdx - -docs/ - tutorial-authoring.md # short guide: how to write a chapter - -package.json # add "dev:tutorial" + "build:tutorial" scripts + @bigmistqke/repl + MDX deps -``` - -Notes on file boundaries: -- `demo.tsx` owns REPL integration only. Layout/styling lives in `index.css`. -- `chapter-loader.ts` is a pure module so it can be unit-tested without DOM. -- `use-active-section.ts` is a Solid primitive — single responsibility (hash sync). -- `mdx-components.tsx` is a one-export module so chapters never import `` manually. - ---- - -## Track A — Project scaffold (Tasks 1–4) - -### Task 1: Create the bare `tutorial/` Vite root - -**Files:** -- Create: `tutorial/index.html` -- Create: `tutorial/vite.config.ts` -- Create: `tutorial/tsconfig.json` -- Create: `tutorial/main.tsx` -- Create: `tutorial/App.tsx` -- Create: `tutorial/index.css` -- Modify: `package.json` (add scripts) - -- [ ] **Step 1: Create `tutorial/index.html`** - -```html - - - - - - solid-three tutorial - - -
- - - -``` - -- [ ] **Step 2: Create `tutorial/vite.config.ts`** (MDX wired up but no chapters yet) - -```ts -import { defineConfig } from "vite" -import solid from "vite-plugin-solid" -import tsconfig from "vite-tsconfig-paths" - -export default defineConfig({ - base: "./", - plugins: [tsconfig(), solid()], -}) -``` - -- [ ] **Step 3: Create `tutorial/tsconfig.json`** - -```json -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "jsx": "preserve", - "jsxImportSource": "solid-js" - }, - "include": ["."] -} -``` - -- [ ] **Step 4: Create `tutorial/main.tsx`** - -```tsx -import { render } from "solid-js/web" -import { App } from "./App" -import "./index.css" - -const root = document.getElementById("root") -if (!root) throw new Error("#root not found") -render(() => , root) -``` - -- [ ] **Step 5: Create `tutorial/App.tsx`** (placeholder) - -```tsx -export function App() { - return

solid-three tutorial

-} -``` - -- [ ] **Step 6: Create `tutorial/index.css`** (empty file with one comment is fine) - -```css -/* tutorial styles — populated in later tasks */ -``` - -- [ ] **Step 7: Add scripts to root `package.json`** - -In the `"scripts"` block, after `"dev"`, add: - -```json -"dev:tutorial": "vite tutorial", -"build:tutorial": "vite build tutorial", -``` - -- [ ] **Step 8: Verify it runs** - -Run: `pnpm dev:tutorial` -Expected: Vite dev server starts, opening the URL shows the "solid-three tutorial" heading. Stop the server (Ctrl-C) after confirming. - -- [ ] **Step 9: Commit** - -```bash -git add tutorial/ package.json -git commit -m "feat(tutorial): scaffold tutorial vite root" -``` - ---- - -### Task 2: Add MDX + Solid integration - -**Files:** -- Modify: `package.json` (add deps) -- Modify: `tutorial/vite.config.ts` -- Create: `tutorial/chapters/00-hello.mdx` (temporary smoke test) -- Modify: `tutorial/App.tsx` - -- [ ] **Step 1: Install MDX dependencies** - -Run: -```bash -pnpm add -D @mdx-js/rollup solid-mdx remark-frontmatter remark-mdx-frontmatter -``` - -Expected: deps added; `pnpm-lock.yaml` updated. - -- [ ] **Step 2: Update `tutorial/vite.config.ts` to wire MDX** - -```ts -import mdx from "@mdx-js/rollup" -import { defineConfig } from "vite" -import solid from "vite-plugin-solid" -import tsconfig from "vite-tsconfig-paths" -import remarkFrontmatter from "remark-frontmatter" -import remarkMdxFrontmatter from "remark-mdx-frontmatter" - -export default defineConfig({ - base: "./", - plugins: [ - tsconfig(), - { - enforce: "pre", - ...mdx({ - jsxImportSource: "solid-js", - providerImportSource: "solid-mdx", - remarkPlugins: [ - remarkFrontmatter, - [remarkMdxFrontmatter, { name: "frontmatter" }], - ], - }), - }, - solid({ extensions: [".mdx"] }), - ], -}) -``` - -Note: `enforce: "pre"` ensures MDX runs before `vite-plugin-solid`, and the `extensions: [".mdx"]` option on `solid()` lets it pick up the MDX output. - -- [ ] **Step 3: Create the smoke-test chapter `tutorial/chapters/00-hello.mdx`** - -```mdx ---- -id: hello -title: Hello MDX -part: 0 ---- - -# Hello from MDX - -This is a paragraph rendered through MDX with Solid. -``` - -- [ ] **Step 4: Update `tutorial/App.tsx` to render it** - -```tsx -import Hello, { frontmatter } from "./chapters/00-hello.mdx" - -export function App() { - return ( -
-

Frontmatter: {JSON.stringify(frontmatter)}

- -
- ) -} -``` - -- [ ] **Step 5: Verify rendering** - -Run: `pnpm dev:tutorial` -Expected: Page shows the `# Hello from MDX` heading, the paragraph, and a line printing the frontmatter object containing `{id:"hello",title:"Hello MDX",part:0}`. Stop the server. - -If MDX fails to compile, the most likely issues are: missing `providerImportSource`, wrong plugin order, or `solid-mdx` not present. Fix before continuing. - -- [ ] **Step 6: Commit** - -```bash -git add tutorial/ package.json pnpm-lock.yaml -git commit -m "feat(tutorial): add MDX-with-Solid integration and smoke chapter" -``` - ---- - -### Task 3: Build the chapter loader - -**Files:** -- Create: `tutorial/chapter-loader.ts` -- Create: `tutorial/chapters/01-hello-canvas.mdx` (placeholder) -- Create: `tutorial/chapters/02-t-proxy.mdx` (placeholder) -- Modify: `tutorial/App.tsx` -- Delete: `tutorial/chapters/00-hello.mdx` - -- [ ] **Step 1: Create two placeholder chapters** - -`tutorial/chapters/01-hello-canvas.mdx`: -```mdx ---- -id: hello-canvas -title: Hello, Canvas -part: 1 -partTitle: Foundations -order: 1 ---- - -# Hello, Canvas - -(chapter content goes here) -``` - -`tutorial/chapters/02-t-proxy.mdx`: -```mdx ---- -id: t-proxy -title: The T proxy -part: 1 -partTitle: Foundations -order: 2 ---- - -# The T proxy - -(chapter content goes here) -``` - -- [ ] **Step 2: Create `tutorial/chapter-loader.ts`** - -```ts -import type { Component } from "solid-js" - -export interface ChapterFrontmatter { - id: string - title: string - part: number - partTitle: string - order: number -} - -export interface ChapterModule { - default: Component - frontmatter: ChapterFrontmatter -} - -const modules = import.meta.glob("./chapters/*.mdx", { - eager: true, -}) - -export const chapters: ChapterModule[] = Object.values(modules).sort((a, b) => { - if (a.frontmatter.part !== b.frontmatter.part) { - return a.frontmatter.part - b.frontmatter.part - } - return a.frontmatter.order - b.frontmatter.order -}) - -export interface Part { - part: number - title: string - chapters: ChapterModule[] -} - -export const parts: Part[] = (() => { - const grouped = new Map() - for (const chapter of chapters) { - const { part, partTitle } = chapter.frontmatter - let entry = grouped.get(part) - if (!entry) { - entry = { part, title: partTitle, chapters: [] } - grouped.set(part, entry) - } - entry.chapters.push(chapter) - } - return [...grouped.values()].sort((a, b) => a.part - b.part) -})() -``` - -- [ ] **Step 3: Delete the smoke chapter** - -Run: -```bash -rm tutorial/chapters/00-hello.mdx -``` - -- [ ] **Step 4: Update `tutorial/App.tsx` to render all chapters in order** - -```tsx -import { For } from "solid-js" -import { chapters } from "./chapter-loader" - -export function App() { - return ( -
- - {chapter => { - const Chapter = chapter.default - return ( -
- -
- ) - }} -
-
- ) -} -``` - -- [ ] **Step 5: Verify** - -Run: `pnpm dev:tutorial` -Expected: Both placeholder chapters render in order (1 then 2). Each is wrapped in a `
` with an id matching its frontmatter. Stop the server. - -- [ ] **Step 6: Commit** - -```bash -git add tutorial/ -git commit -m "feat(tutorial): add chapter loader sorted by part and order" -``` - ---- - -### Task 4: Sidebar with smooth scrolling - -**Files:** -- Create: `tutorial/sidebar.tsx` -- Modify: `tutorial/App.tsx` -- Modify: `tutorial/index.css` - -- [ ] **Step 1: Create `tutorial/sidebar.tsx`** - -```tsx -import { For } from "solid-js" -import { parts } from "./chapter-loader" - -export function Sidebar() { - return ( - - ) -} -``` - -- [ ] **Step 2: Update `tutorial/App.tsx` to render the sidebar alongside the body** - -```tsx -import { For } from "solid-js" -import { chapters } from "./chapter-loader" -import { Sidebar } from "./sidebar" - -export function App() { - return ( -
- -
- - {chapter => { - const Chapter = chapter.default - return ( -
- -
- ) - }} -
-
-
- ) -} -``` - -- [ ] **Step 3: Add layout styles to `tutorial/index.css`** - -```css -:root { - --sidebar-width: 260px; - --reading-max: 70ch; - --color-bg: #0f1117; - --color-fg: #e8e8e8; - --color-muted: #8b8f9a; - --color-accent: #d39bff; -} - -html, body { - margin: 0; - background: var(--color-bg); - color: var(--color-fg); - font-family: ui-sans-serif, system-ui, sans-serif; - scroll-behavior: smooth; -} - -.tutorial-layout { - display: grid; - grid-template-columns: var(--sidebar-width) 1fr; - min-height: 100vh; -} - -.sidebar { - position: sticky; - top: 0; - align-self: start; - height: 100vh; - overflow-y: auto; - padding: 1.5rem 1rem; - border-right: 1px solid #222; - font-size: 0.9rem; -} - -.sidebar-part-title { - margin: 1rem 0 0.25rem; - font-size: 0.75rem; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-muted); -} - -.sidebar-chapter-list { - list-style: none; - padding: 0; - margin: 0; -} - -.sidebar-chapter-list a { - display: block; - padding: 0.25rem 0.5rem; - border-radius: 4px; - color: var(--color-fg); - text-decoration: none; -} - -.sidebar-chapter-list a:hover { - background: #1a1d26; -} - -.sidebar-chapter-list a.active { - background: #1a1d26; - color: var(--color-accent); -} - -.tutorial-body { - padding: 3rem 4rem; - display: flex; - flex-direction: column; - gap: 4rem; -} - -.tutorial-chapter { - max-width: var(--reading-max); -} - -.tutorial-chapter h1 { - margin-top: 0; -} -``` - -- [ ] **Step 4: Verify** - -Run: `pnpm dev:tutorial` -Expected: Sidebar shows "Part 1: Foundations" with two links ("Hello, Canvas", "The T proxy"). Clicking each scrolls to the corresponding section. Stop the server. - -- [ ] **Step 5: Commit** - -```bash -git add tutorial/ -git commit -m "feat(tutorial): add sidebar grouped by parts with anchor links" -``` - ---- - -### Task 5: Active-section hash sync - -**Files:** -- Create: `tutorial/use-active-section.ts` -- Modify: `tutorial/sidebar.tsx` - -- [ ] **Step 1: Create `tutorial/use-active-section.ts`** - -```ts -import { createSignal, onCleanup, onMount } from "solid-js" - -export function useActiveSection(selector: string) { - const [activeId, setActiveId] = createSignal(null) - - onMount(() => { - const elements = Array.from(document.querySelectorAll(selector)) - if (elements.length === 0) return - - const observer = new IntersectionObserver( - entries => { - const visible = entries - .filter(entry => entry.isIntersecting) - .sort((a, b) => a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top) - if (visible[0]) { - const id = visible[0].target.id - setActiveId(id) - if (id && history.replaceState) { - history.replaceState(null, "", `#${id}`) - } - } - }, - { rootMargin: "0px 0px -70% 0px", threshold: 0 }, - ) - - for (const element of elements) observer.observe(element) - onCleanup(() => observer.disconnect()) - }) - - return activeId -} -``` - -- [ ] **Step 2: Update `tutorial/sidebar.tsx` to highlight the active link** - -```tsx -import { For } from "solid-js" -import { parts } from "./chapter-loader" -import { useActiveSection } from "./use-active-section" - -export function Sidebar() { - const activeId = useActiveSection(".tutorial-chapter") - return ( - - ) -} -``` - -- [ ] **Step 3: Verify** - -Run: `pnpm dev:tutorial` -Expected: Scrolling between the two sections updates the active sidebar link and the URL hash. Stop the server. - -- [ ] **Step 4: Commit** - -```bash -git add tutorial/ -git commit -m "feat(tutorial): sync URL hash + sidebar active state with scroll position" -``` - ---- - -## Track B — Demo wrapper (Tasks 6–9) - -### Task 6: Stub `` + MDX component injection - -This task introduces `` as a static stub (renders code in a `
` next to a placeholder canvas) so chapter authors have a stable API while we sort out the REPL plumbing.
-
-**Files:**
-- Create: `tutorial/demo.tsx`
-- Create: `tutorial/mdx-components.tsx`
-- Modify: `tutorial/vite.config.ts`
-- Modify: `tutorial/main.tsx`
-
-- [ ] **Step 1: Create the stub `tutorial/demo.tsx`**
-
-```tsx
-import type { JSX } from "solid-js"
-
-export interface DemoProps {
-  code: string
-  children?: JSX.Element
-}
-
-export function Demo(props: DemoProps) {
-  return (
-    
-
-        {props.code}
-      
-
- canvas placeholder (REPL integration pending — Task 8) -
-
- ) -} -``` - -- [ ] **Step 2: Create `tutorial/mdx-components.tsx`** - -```tsx -import type { Component } from "solid-js" -import { Demo } from "./demo" - -export const mdxComponents: Record> = { - Demo, -} -``` - -- [ ] **Step 3: Provide the components to MDX** - -`solid-mdx` exposes an `` similar to React MDX. Update `tutorial/main.tsx`: - -```tsx -import { render } from "solid-js/web" -import { MDXProvider } from "solid-mdx" -import { App } from "./App" -import { mdxComponents } from "./mdx-components" -import "./index.css" - -const root = document.getElementById("root") -if (!root) throw new Error("#root not found") -render( - () => ( - - - - ), - root, -) -``` - -If `solid-mdx`'s API differs (e.g., named differently as `MDXProvider`), inspect its package exports and adjust. The contract here is: chapters reference `` without importing it. - -- [ ] **Step 4: Add minimal styles to `tutorial/index.css`** - -Append: - -```css -.demo { - margin: 1.5rem 0; - border: 1px solid #222; - border-radius: 6px; - overflow: hidden; -} - -.demo-code { - margin: 0; - padding: 1rem; - background: #0a0c12; - font-size: 0.85rem; - overflow-x: auto; -} - -.demo-canvas-placeholder { - padding: 2rem; - text-align: center; - color: var(--color-muted); - background: #14171f; -} -``` - -- [ ] **Step 5: Exercise it from a chapter** - -Replace `tutorial/chapters/01-hello-canvas.mdx` body to include a ``: - -```mdx ---- -id: hello-canvas -title: Hello, Canvas -part: 1 -partTitle: Foundations -order: 1 ---- - -# Hello, Canvas - -Every solid-three app starts by mounting a ``. - - -`} /> - -The canvas is empty — we haven't put anything in the scene yet. -``` - -- [ ] **Step 6: Verify** - -Run: `pnpm dev:tutorial` -Expected: Chapter 1 shows the heading, the paragraph, a code block displaying the snippet, the placeholder div, then the closing paragraph. No errors. Stop the server. - -- [ ] **Step 7: Commit** - -```bash -git add tutorial/ -git commit -m "feat(tutorial): add stub and MDX provider wiring" -``` - ---- - -### Task 7: Wire `@bigmistqke/repl` into `` - -This task assumes `../repl` is consumed via a local relative dependency. No upstream changes required — `@bigmistqke/repl`'s `transformModulePaths` already accepts a custom resolver that can map specifiers like `solid-three` to any URL. - -**Files:** -- Modify: `package.json` (dependency) -- Modify: `tutorial/vite.config.ts` -- Modify: `tutorial/demo.tsx` - -- [ ] **Step 1: Add `@bigmistqke/repl` as a local relative dependency** - -Edit root `package.json`, in `devDependencies`, add: -```json -"@bigmistqke/repl": "link:../repl" -``` - -Then run: -```bash -pnpm install -``` - -Expected: pnpm symlinks `../repl` into `node_modules`. If `../repl` is not yet built, run `pnpm --dir ../repl build` first. - -- [ ] **Step 2: Expose `solid-three` and `three` as bundle URLs** - -`solid-three` is sourced from `../src`. The host Vite dev server already serves it. We need a URL the REPL iframe can fetch. Use a Vite virtual module + a query-suffixed URL strategy. - -Add to `tutorial/vite.config.ts`: - -```ts -import { defineConfig } from "vite" -// ... existing imports - -export default defineConfig({ - base: "./", - resolve: { - alias: { - "solid-three": new URL("../src/index.ts", import.meta.url).pathname, - }, - }, - // ... existing plugins -}) -``` - -This makes `solid-three` resolve to the local source for the *host* page. The REPL still needs separate mapping for *snippet* code, handled in Step 3. - -- [ ] **Step 3: Replace `tutorial/demo.tsx` with the live REPL wrapper** - -```tsx -import { createFileUrlSystem, transformModulePaths, PathUtils, type Extension } from "@bigmistqke/repl" -import { createSignal, onMount, Show } from "solid-js" -import ts from "typescript" - -const externalEsmHost = "https://esm.sh" - -// Map a bare specifier to a URL the iframe can fetch. -// `solid-three` and `three` resolve to the host dev server's served modules; -// everything else falls through to esm.sh. -function resolveBare(specifier: string): string { - if (specifier === "solid-three") { - return new URL("/@fs" + new URL("../src/index.ts", import.meta.url).pathname, location.origin).toString() - } - if (specifier === "three") { - return `${externalEsmHost}/three` - } - return `${externalEsmHost}/${specifier}` -} - -const tsExtension = { - type: "javascript", - transform: ({ source, path, fileUrls }) => { - const jsSource = ts.transpile(source, { jsx: ts.JsxEmit.Preserve }) - return transformModulePaths({ - ts, - source: jsSource, - transform: modulePath => { - if (modulePath.startsWith(".")) { - return fileUrls.get(PathUtils.resolvePath(path, modulePath)) - } - return resolveBare(modulePath) - }, - }) - }, -} satisfies Extension - -export interface DemoProps { - code: string - /** stable key for localStorage; defaults to the code hash */ - id?: string -} - -const STORAGE_PREFIX = "solid-three-tutorial-demo:" - -function storageKey(props: DemoProps): string { - return STORAGE_PREFIX + (props.id ?? hash(props.code)) -} - -function hash(input: string): string { - let h = 0 - for (let i = 0; i < input.length; i++) { - h = (h << 5) - h + input.charCodeAt(i) - h |= 0 - } - return h.toString(36) -} - -export function Demo(props: DemoProps) { - const initialCode = (): string => { - const stored = localStorage.getItem(storageKey(props)) - return stored ?? props.code - } - - const [code, setCode] = createSignal(initialCode()) - const [pane, setPane] = createSignal<"canvas" | "editor">("canvas") - const [isNarrow, setIsNarrow] = createSignal(false) - - onMount(() => { - const media = window.matchMedia("(max-width: 900px)") - setIsNarrow(media.matches) - const handler = (event: MediaQueryListEvent) => setIsNarrow(event.matches) - media.addEventListener("change", handler) - }) - - function update(next: string) { - setCode(next) - localStorage.setItem(storageKey(props), next) - } - - function reset() { - localStorage.removeItem(storageKey(props)) - setCode(props.code) - } - - // Build the file URL system per-snippet (so each demo runs in isolation) - const fileSystem = createFileUrlSystem({ - readFile: path => (path === "/index.tsx" ? code() : undefined), - extensions: { tsx: tsExtension }, - }) - - const iframeSrc = () => fileSystem.get("/index.tsx") ?? "about:blank" - - return ( -
- -
- - -
-
-
- -