diff --git a/.changeset/web-preserve-modules.md b/.changeset/web-preserve-modules.md new file mode 100644 index 000000000..03ff5e1f2 --- /dev/null +++ b/.changeset/web-preserve-modules.md @@ -0,0 +1,6 @@ +--- +"@solidjs/web": patch +"@solidjs/babel-plugin": patch +--- + +`@solidjs/web`'s client entry is now built per module (`preserveModules`): `dist/web.js` keeps its name and export set, and the runtime's modules sit beside it under `dist/web/` (likewise `dist/web.dev/` and `dist/web.observe/` for the other tiers). The attribute runtime (`assign`, `spread`, the per-prop writers), the children runtime (`insert`) and the attribute tables are their own source modules, so an application bundler places each by its importers: on a server-component page whose only importer of `assign` is the lazy frames bind tier, the attribute runtime now travels in that chunk instead of the eager entry. No runtime behaviour change; the public export set of every entry is unchanged. `@solidjs/babel-plugin` reads the shared attribute tables from their new module (no change to its output). diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 6e8d8f308..d224a435e 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -728,6 +728,40 @@ Each a yes/no with a recommendation. matters for what C3 and C6 leave behind on a page (≈ 1.3 KB of adapters, ≈ 2.4 KB of `dynamic`'s string tag). **Ruled 2026-10-06: yes** — `preserveModules` after the tiers (Phase D). + **Landed for `@solidjs/web` (2026-10-07, `build/web-preserve-modules`, + on `feat/web-dynamic-component`):** the client entry per module, with + `client.ts` split so the attribute runtime (`src/client/attributes.ts`) + and the children runtime (`src/client/insert.ts`) are modules of their + own and the four attribute tables leave `constants.ts` + (`src/attribute-tables.ts`); public export sets unchanged. On the + component-only pages: page base 33,570 → **32,972 br** (−3,694 min / + −598 br), `bind.js` 1,834 → 3,224 br lazy (`assign` and below); page + live 37,241 → 37,244 (flat). Two findings bound the win. (i) **The + store hydration adapters do not move**: they live in `solid-js`'s flat + `client/hydration.ts` and `enableHydration()` installs + `hydrateStoreLike` statically on every hydrating page (B.1, rejected + 2026-09-26), and `trace.js` imports nothing from `@solidjs/web` — the + ≈ 1.1 KB br `trace.js` pins in the page entry (measured: −1,136 with + the tier external) is signals' store-type predicates shared with the + entry plus the 63-binding export glue, a `solid-js` / `@solidjs/signals` + module-shape question, not this step's. (ii) **Rolldown emits a second + eager chunk on a server-component page** once `@solidjs/web` is per + module: a module the entry shares with one lazy tier only (`client.js` + core, through `runHydrationEvents` and `assign`'s imports) imports the + runtime every tier shares, and Rolldown's greedy common-chunk merge + rejects folding the shared runtime into the entry as a chunk cycle + (order-dependent; Rollup folds the same graph into one chunk — repro + under the audit workspace's `.wt-logs/pm-rolldown-repro/mirror`). The + page then ships the entry plus a statically imported common chunk: + ≈ +330 min of cross-chunk glue on page base, ≈ +1,200 on live (its + entry reads more signals internals), and per-file compression costs + ≈ 500–900 br against the one-stream figure (page base would be 32,433 + as one stream, live 36,356). The size harness now measures the eager + graph — the entry and every chunk it imports statically — per file, + with the one-stream figure beside it. A compiled server-component page + imports the attribute writers itself, so there the split cannot move + them and only the glue remains; the fix is upstream (merge common + chunks to a fixed point). 8. **Claims + `frame:applied` ride the router's chunk rather than a frames tier?** — **Recommend yes**: 331 br, not worth a seam; the router is the only installer of `CLAIM_SEAM` and the only consumer of diff --git a/packages/babel-plugin/src/dom/element.ts b/packages/babel-plugin/src/dom/element.ts index 881168798..3b403e5b6 100644 --- a/packages/babel-plugin/src/dom/element.ts +++ b/packages/babel-plugin/src/dom/element.ts @@ -2,14 +2,8 @@ import * as babelTypes from "@babel/types"; const t = babelTypes; -import { - ChildProperties, - DelegatedEvents, - SVGElements, - MathMLElements, - Namespaces, - VoidElements -} from "../../../web/src/constants.js"; +import { SVGElements, MathMLElements, VoidElements } from "../../../web/src/constants.js"; +import { ChildProperties, DelegatedEvents, Namespaces } from "../../../web/src/attribute-tables.js"; import { evaluateAndInline, getAttributeNamed, diff --git a/packages/babel-plugin/src/shared/transform.ts b/packages/babel-plugin/src/shared/transform.ts index 4141b247b..f5fc01ff3 100644 --- a/packages/babel-plugin/src/shared/transform.ts +++ b/packages/babel-plugin/src/shared/transform.ts @@ -18,7 +18,7 @@ import { transformSpecialCaseAttributes, renameElementKey } from "./utils"; -import { DOMWithState } from "../../../web/src/constants.js"; +import { DOMWithState } from "../../../web/src/attribute-tables.js"; import transformComponent from "./component"; import transformFragmentChildren from "./fragment"; import type { NodePath } from "@babel/traverse"; diff --git a/packages/babel-plugin/src/shared/utils.ts b/packages/babel-plugin/src/shared/utils.ts index 3e86091b1..b48a1c2d2 100644 --- a/packages/babel-plugin/src/shared/utils.ts +++ b/packages/babel-plugin/src/shared/utils.ts @@ -1,6 +1,6 @@ import * as t from "@babel/types"; import { addNamed } from "@babel/helper-module-imports"; -import { DOMWithState } from "../../../web/src/constants.js"; +import { DOMWithState } from "../../../web/src/attribute-tables.js"; import type { NodePath, Visitor } from "@babel/traverse"; import type { PluginConfig, RendererConfig } from "../config"; import type { diff --git a/packages/babel-plugin/src/ssr/element.ts b/packages/babel-plugin/src/ssr/element.ts index 89ef51945..4096bb64e 100644 --- a/packages/babel-plugin/src/ssr/element.ts +++ b/packages/babel-plugin/src/ssr/element.ts @@ -2,7 +2,8 @@ import * as babelTypes from "@babel/types"; const t = babelTypes; import { decode } from "html-entities"; -import { ChildProperties, VoidElements } from "../../../web/src/constants.js"; +import { VoidElements } from "../../../web/src/constants.js"; +import { ChildProperties } from "../../../web/src/attribute-tables.js"; import { evaluateAndInline, getTagName, diff --git a/packages/babel-plugin/tsconfig.json b/packages/babel-plugin/tsconfig.json index 345b9cb6a..28462f9ed 100644 --- a/packages/babel-plugin/tsconfig.json +++ b/packages/babel-plugin/tsconfig.json @@ -8,5 +8,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["src", "../web/src/constants.ts"] + "include": ["src", "../web/src/constants.ts", "../web/src/attribute-tables.ts"] } diff --git a/packages/web/rollup.config.js b/packages/web/rollup.config.js index 570dfe872..566006385 100644 --- a/packages/web/rollup.config.js +++ b/packages/web/rollup.config.js @@ -191,25 +191,47 @@ const assertFramesClientTransport = { } }; -export default [ - { - input: "src/index.ts", - output: { file: "dist/web.js", format: "es" }, - external: ["solid-js", "solid-js/internal"], - plugins: [replaceDev(false)].concat(plugins) - }, - { - // Observe client build (`observe` condition under `browser`): the three - // interaction-provenance wraps in src/client.ts survive so attribution can - // stamp root writes with the event that caused them; every dev-only check - // folds out. The frames and server-functions clients have observe builds - // of their own (their `"frame"`/`"call"` records); storage has no wiring - // and falls through to prod under `observe`. - input: "src/index.ts", - output: { file: "dist/web.observe.js", format: "es" }, - external: ["solid-js", "solid-js/internal"], - plugins: [replaceFlags(false, true)].concat(plugins) +// The main CLIENT entry is built per module (`preserveModules`), one file per +// source module, so an application's bundler assigns each module to a chunk +// by its importers instead of carrying the whole runtime in the entry: on a +// server-component page the attribute runtime (src/client/attributes.ts — +// `assign`, `spread`, the per-prop writers) has one importer, the lazy frames +// bind tier, and lands in that chunk; a flat `web.js` put it in the entry for +// every page (chunk assignment is per MODULE, and a flat dist is one module — +// documentation/plans/frames-b3-sync.md §1–2 measured the difference). Every +// module is `sideEffects: false` (package.json), so a module nothing imports is +// dropped outright. The entry keeps its historical file name (`dist/web.js`, +// what `exports`, `unpkg` and the size harness point at); the other modules +// of a tier sit under a directory of the same name (`dist/web/client.js`, +// `dist/web.dev/client.js`, …) so the three tiers coexist in `dist/`. The one +// module from outside src/ (server-functions/src/registry.ts, the +// server-function detection seam) lands as `server-functions/registry.js`, +// the path its published declaration uses. The server entry stays a single +// file: a server bundle does not split by route. +const clientBuild = (tier, flags) => ({ + input: "src/index.ts", + output: { + dir: "dist", + format: "es", + preserveModules: true, + preserveModulesRoot: "src", + entryFileNames: ({ name }) => + name === "index" ? `${tier}.js` : `${tier}/${name.replace(/\/src\//, "/")}.js` }, + external: ["solid-js", "solid-js/internal"], + plugins: [flags].concat(plugins) +}); + +export default [ + clientBuild("web", replaceDev(false)), + // Observe client build (`observe` condition under `browser`): the three + // interaction-provenance wraps (src/render.ts, used by the delegated + // dispatch in src/client.ts and `addEvent` in src/client/attributes.ts) + // survive so attribution can stamp root writes with the event that caused + // them; every dev-only check folds out. The frames and server-functions + // clients have observe builds of their own (their `"frame"`/`"call"` + // records); storage has no wiring and falls through to prod under `observe`. + clientBuild("web.observe", replaceFlags(false, true)), { // Prod server build — the default node/worker/deno artifact for the main // entry. `_SOLID_DEV_` must strip to false here: without the replace, babel @@ -250,12 +272,7 @@ export default [ external: ["solid-js", "solid-js/internal", "stream", "seroval", "seroval-plugins/web"], plugins: [replaceFlags(false, true)].concat(plugins) }, - { - input: "src/index.ts", - output: { file: "dist/web.dev.js", format: "es" }, - external: ["solid-js", "solid-js/internal"], - plugins: [replaceDev(true)].concat(plugins) - }, + clientBuild("web.dev", replaceDev(true)), { input: "storage/src/index.ts", output: { file: "storage/dist/storage.js", format: "es" }, diff --git a/packages/web/scripts/copy-types.mjs b/packages/web/scripts/copy-types.mjs index f801da303..c2d735c9e 100644 --- a/packages/web/scripts/copy-types.mjs +++ b/packages/web/scripts/copy-types.mjs @@ -13,14 +13,19 @@ import path from "node:path"; const root = path.resolve(import.meta.dirname, ".."); const build = path.join(root, ".types-build"); -function rewritePublished(s, kind) { +function rewritePublished(s, kind, depth = 0) { if (kind === "src") { + // A declaration one directory down (src/client/*.d.ts) reaches the + // package root with one more `../`; its published copy sits one level + // down too, so the rewritten specifiers gain the same prefix. + const up = "../".repeat(depth); + const to = depth ? up : "./"; return s - .replaceAll("../jsx/jsx.js", "./jsx.js") - .replaceAll("../server-functions/src/shared.js", "./server-functions/shared.js") - .replaceAll("../server-functions/src/registry.js", "./server-functions/registry.js") - .replaceAll("../serialization/src/serializer-decode.js", "./serializer-decode.js") - .replaceAll("../serialization/src/serializer.js", "./serializer.js"); + .replaceAll(`${up}../jsx/jsx.js`, `${to}jsx.js`) + .replaceAll(`${up}../server-functions/src/shared.js`, `${to}server-functions/shared.js`) + .replaceAll(`${up}../server-functions/src/registry.js`, `${to}server-functions/registry.js`) + .replaceAll(`${up}../serialization/src/serializer-decode.js`, `${to}serializer-decode.js`) + .replaceAll(`${up}../serialization/src/serializer.js`, `${to}serializer.js`); } if (kind === "server-functions") { return s @@ -41,12 +46,19 @@ function rewritePublished(s, kind) { return s; } -function copyDir(from, to, kind) { +// Recursive: src/client/ (the children and attribute runtimes) emits into a +// subdirectory, published as types/client/. +function copyDir(from, to, kind, depth = 0) { fs.mkdirSync(to, { recursive: true }); - for (const name of fs.readdirSync(from)) { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const name = entry.name; + if (entry.isDirectory()) { + copyDir(path.join(from, name), path.join(to, name), kind, depth + 1); + continue; + } if (!name.endsWith(".d.ts") && !name.endsWith(".d.ts.map")) continue; const src = fs.readFileSync(path.join(from, name), "utf8"); - fs.writeFileSync(path.join(to, name), rewritePublished(src, kind)); + fs.writeFileSync(path.join(to, name), rewritePublished(src, kind, depth)); } } diff --git a/packages/web/src/attribute-tables.ts b/packages/web/src/attribute-tables.ts new file mode 100644 index 000000000..1b37398ec --- /dev/null +++ b/packages/web/src/attribute-tables.ts @@ -0,0 +1,66 @@ +// The tables the attribute runtime (client/attributes.ts) consults when it +// writes a prop onto an element, shared with the SSR element serializer +// (server.ts) and the hydration script's event capture. Their own module, +// apart from constants.ts: a bundler assigns modules to chunks whole, and +// constants.ts sits in every page's eager graph (the slot / host symbols the +// children runtime tags nodes with), so these four would be pinned there for +// a lazy chunk that imports `assign` — the frames bind tier on a server- +// component page — instead of travelling with it. Nothing here depends on +// anything else in the package. + +/** + * Flags + * + * - 1 - Stateful property - value derives from reactive state + * - 2 - Locked to property - value not specially treated + */ +const DOMWithState: Record> = { + INPUT: { value: 1, defaultValue: 2, checked: 1, defaultChecked: 2 }, + SELECT: { value: 1 }, + OPTION: { value: 1, selected: 1, defaultSelected: 2 }, + TEXTAREA: { value: 1, defaultValue: 2 }, + VIDEO: { muted: 1, defaultMuted: 2 }, + AUDIO: { muted: 1, defaultMuted: 2 } +}; + +const ChildProperties = /*#__PURE__*/ new Set([ + "innerHTML", + "textContent", + "innerText", + "children" +]); + +// list of Element events that will be delegated +const DelegatedEvents = /*#__PURE__*/ new Set([ + "beforeinput", + "click", + "dblclick", + "contextmenu", + "focusin", + "focusout", + "input", + "keydown", + "keyup", + "mousedown", + "mousemove", + "mouseout", + "mouseover", + "mouseup", + "pointerdown", + "pointermove", + "pointerout", + "pointerover", + "pointerup", + "touchend", + "touchmove", + "touchstart" +]); + +const Namespaces: Record = { + svg: "http://www.w3.org/2000/svg", + mathml: "http://www.w3.org/1998/Math/MathML", + xlink: "http://www.w3.org/1999/xlink", + xml: "http://www.w3.org/XML/1998/namespace" +}; + +export { DOMWithState, ChildProperties, DelegatedEvents, Namespaces }; diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index e8f52ee82..5f08a47d1 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -1,5 +1,13 @@ // @ts-nocheck -import { ChildProperties, Namespaces, DelegatedEvents, $$SLOT, $$HOST } from "./constants.js"; +// The client runtime's core: render / hydrate, the template and hydration-walk +// helpers, element claims, delegated-event dispatch, the asset and head +// registries. Two halves live in their own modules so a bundler can place +// them by importer rather than by file: the children runtime +// (client/insert.ts — `insert`, and the one-shot `insertExpression` the +// attribute runtime writes children through) and the attribute runtime +// (client/attributes.ts — `assign`, `spread`, the per-prop writers). This +// module imports from the former and never from the latter. +import { EVENT_KEY, $$SLOT, $$HOST } from "./constants.js"; import { getOwner, runWithOwner, @@ -9,32 +17,24 @@ import { onCleanup, untrack, merge as mergeProps, - $PROXY, flatten, createMemo, flush, enableHydration, enforceLoadingBoundary, resetErrorHalt, - ROOT_ERROR_HOOK, - OBSERVE + ROOT_ERROR_HOOK } from "solid-js"; import type { ClientErrorHook } from "solid-js"; +import { sharedConfig } from "solid-js/internal"; +import { effect, memo, tagElement, dispatchAsInteraction } from "./render.js"; import { - sharedConfig, - viewOf, - OmitView, - sourceKeys, - sourceHas, - sourceGet, - hasStaticKeys, - resolvedTable, - SOURCE_PLAIN, - SOURCE_OMIT, - SOURCE_PROXY, - SOURCE_MEMO -} from "solid-js/internal"; -import { effect, memo, setSpreadName, spreadName, tagElement } from "./render.js"; + insert, + installHydrationRuntime, + isHydrating, + hydrationRt, + noteHydrationKeyMiss +} from "./client/insert.js"; import { JSX } from "../jsx/jsx.js"; @@ -171,8 +171,6 @@ export const waitAsset = (promise: Promise): void => { gate(); }; -import reconcileArrays from "./reconcile.js"; -import { DOMWithState } from "./constants.js"; import { HEAD_ELIGIBLE_TAGS, HEAD_ATTR_NAME, @@ -186,17 +184,13 @@ import { qualifierValue, STYLESHEET_FETCH_META } from "./head.js"; -import { devCheck, lowercaseEventAttribute, unscopedHoleAllocatedIds } from "./diagnostics.js"; +import { devCheck } from "./diagnostics.js"; export { - DOMWithState, - ChildProperties, DOMElements, SVGElements, MathMLElements, VoidElements, - RawTextElements, - Namespaces, - DelegatedEvents + RawTextElements } from "./constants.js"; // === Delegated-event wire contract === @@ -205,7 +199,8 @@ export { // Solid runtime happens to be listening, so it is shared state between every // Solid copy on a page: two bundles of the same major nested in each other, // or a future major nested in this one. It is frozen. Changing any of it -// means choosing a new EVENT_KEY prefix so the runtimes stop seeing each +// means choosing a new EVENT_KEY prefix (constants.ts; `addEvent` in +// client/attributes.ts writes under it) so the runtimes stop seeing each // other's handlers, not a new shape under the old one. // // - `node[EVENT_KEY + type]` is the delegated handler: a function or an @@ -224,11 +219,7 @@ export { // matching key it found. The prefix here is deliberately not `$$` so a v1 // copy on the same page (an older widget, a devtools panel) cannot find // these handlers, and this runtime cannot find v1's. -const EVENT_KEY = "_$$"; const $$EVENT_OWNER = "_$SOLID_EVENT_OWNER"; -const $$EVENT_TUPLE = Symbol(); -const hasOwn = Object.prototype.hasOwnProperty; -const INNER_OWNED = {}; const delegatedEvents = new Set(); const delegatedContainers = new Map(); @@ -515,55 +506,7 @@ function attachDelegatedEvent(name, container, state) { container.addEventListener(name, handler); } -// === Interaction provenance (observe tier) === -// -// Delegated events — every INP-relevant type: click, input, keydown, -// pointer*… — reach user code through the dispatch above, and runtime-attached -// direct handlers (spreads, non-literal handler expressions) through addEvent. -// Wrapping those two in the signals attribution engine's `withInteraction` -// stamps every root write a handler performs with the event that caused it -// (`click on button#next "Next →"`) — what turns a transition hold or a hot -// scope into a per-interaction number. Not covered: non-delegated events -// whose handler is a literal function (the compiler emits a bare -// `addEventListener` for those) and hand-written `ref`-based listeners. - -/** `button#next "Next →"`, `input[name=q]`, `a "Docs"` — what the user hit. */ -function describeEventTarget(target) { - if (!target || typeof target.tagName !== "string") return undefined; - const tag = target.tagName.toLowerCase(); - let out = tag; - if (target.id) out += `#${target.id}`; - else if (typeof target.name === "string" && target.name) out += `[name=${target.name}]`; - if (tag !== "input" && tag !== "textarea" && tag !== "select") { - const text = (target.textContent || "").trim().replace(/\s+/g, " "); - if (text) out += ` "${text.length > 30 ? text.slice(0, 29) + "…" : text}"`; - } - return out; -} - -/** - * The interaction's start on the `performance.now()` clock: the event's own - * `timeStamp` — when the browser created it, before any queued task ran — - * not the moment the handler was reached, so the wait the record measures - * begins where the user's does. It is also the join key to the browser's - * Event Timing entry for the same interaction (`PerformanceEventTiming - * .startTime` equals it), which is how a consumer lines an interaction - * record up with INP without a time-window guess. Guarded: an environment - * that still stamps events with epoch milliseconds (jsdom, pre-2016 - * browsers) puts the value far past `performance.now()`, and a value from - * the wrong clock is worse than none — the engine then defaults to now. - */ -function interactionStart(e) { - const at = e.timeStamp; - return typeof at === "number" && at >= 0 && at <= performance.now() ? at : undefined; -} - -function dispatchAsInteraction(e, fn) { - return OBSERVE.attribution.withInteraction( - { type: e.type, target: describeEventTarget(e.target), at: interactionStart(e) }, - fn - ); -} /** Event-delegation plumbing (Portal/custom-root wiring). Integration plumbing. @internal */ +/** Event-delegation plumbing (Portal/custom-root wiring). Integration plumbing. @internal */ export function getDelegatedRoot(node: MountableElement): MountableElement | undefined; export function getDelegatedRoot(node) { @@ -581,27 +524,6 @@ function findOwner(target, state) { distance++; node = node._$host || node.parentNode || node.host; } -} /** Compiler-emitted primitive; not for hand-written code. @internal */ -export function setProperty(node: Element, name: string, value: any): void; - -export function setProperty(node, name, value) { - if ("_SOLID_DEV_") tagElement(node); - if (isHydrating(node)) return; - // Stateful DOM properties (DOMWithState) route through here in hydratable - // builds so the claim pass adopts pre-hydration user state instead of - // clobbering it (#3182). Mirror the special cases the compiler emits for - // the direct-assignment path: