What version of Effect is running?
effect 4.0.0-rc.117, @effect/atom-react 4.0.0-rc.117 (same imports on main at 330b747)
What steps can reproduce the bug?
A module that only calls Atom.make has Schema and the persistence store in its static import graph.
main has the same imports at 330b747:
The script below reads esbuild's metafile.
package.json
{
"private": true,
"type": "module",
"dependencies": {
"effect": "4.0.0-rc.117"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}
entry.js
import * as Atom from "effect/unstable/reactivity/Atom"
export const count = Atom.make(0)
chains.mjs
// Which static imports bring Schema and the persistence store into a bundle that only calls Atom.make,
// and how much code sits behind them. Reads esbuild's metafile. Usage: node chains.mjs
import * as esbuild from "esbuild"
import { gzipSync } from "node:zlib"
const build = (treeShaking) =>
esbuild.build({
entryPoints: ["entry.js"], bundle: true, minify: true, treeShaking, format: "esm", platform: "browser",
write: false, metafile: true,
})
const kB = (n) => `${(n / 1000).toFixed(1)} kB`
const short = (p) => p.replace(/^.*node_modules\/(\.pnpm\/[^/]+\/node_modules\/)?/, "")
const bytes = (r) => new Map(Object.entries(Object.values(r.metafile.outputs)[0].inputs).map(([p, i]) => [p, i.bytesInOutput]))
const report = (label, r) => {
const out = r.outputFiles[0].contents
const modules = [...bytes(r).values()].filter((b) => b > 0).length
console.log(`${label}: ${kB(out.length)} minified, ${kB(gzipSync(out, { level: 9 }).length)} gzip, ${modules} modules`)
}
const shaken = await build(true)
const whole = await build(false)
report("tree-shaken ", shaken)
report("no tree-shaking ", whole)
const graph = new Map(Object.entries(whole.metafile.inputs).map(([p, i]) => [p, i.imports.map((x) => x.path)]))
const reach = (cut = () => false) => {
const seen = new Set(["entry.js"])
for (const from of seen) for (const to of graph.get(from) ?? []) if (!cut(from, to)) seen.add(to)
return seen
}
const all = reach()
const families = {
"Schema*, internal/schema/*": /\/effect\/dist\/(Schema\w*\.js|internal\/schema\/)/,
"unstable/persistence/*": /\/effect\/dist\/unstable\/persistence\//,
}
for (const [name, re] of Object.entries(families)) {
const inside = (p) => re.test(p)
const rest = reach((from, to) => inside(to) && !inside(from))
const only = [...all].filter((p) => !rest.has(p))
const weight = only.reduce((n, p) => n + bytes(whole).get(p), 0)
const kept = only.reduce((n, p) => n + (bytes(shaken).get(p) ?? 0), 0)
console.log(`\n${name}: ${only.length} modules (${kB(weight)} minified) are reachable only through it; esbuild keeps ${kB(kept)}`)
for (const [from, deps] of graph) for (const to of deps) if (inside(to) && rest.has(from)) console.log(` ${short(from)} -> ${short(to)}`)
}
pnpm install
node chains.mjs
Output (Node 24.19.0, pnpm 12.6.0):
tree-shaken : 76.4 kB minified, 25.5 kB gzip, 51 modules
no tree-shaking : 516.5 kB minified, 160.1 kB gzip, 138 modules
Schema*, internal/schema/*: 44 modules (205.3 kB minified) are reachable only through it; esbuild keeps 0.0 kB
effect/dist/unstable/persistence/KeyValueStore.js -> effect/dist/Schema.js
effect/dist/unstable/reactivity/AsyncResult.js -> effect/dist/Schema.js
effect/dist/unstable/reactivity/AsyncResult.js -> effect/dist/SchemaIssue.js
effect/dist/unstable/reactivity/AsyncResult.js -> effect/dist/SchemaParser.js
effect/dist/unstable/reactivity/AsyncResult.js -> effect/dist/SchemaTransformation.js
effect/dist/unstable/reactivity/Atom.js -> effect/dist/Schema.js
unstable/persistence/*: 7 modules (28.0 kB minified) are reachable only through it; esbuild keeps 0.0 kB
effect/dist/unstable/reactivity/Atom.js -> effect/dist/unstable/persistence/KeyValueStore.js
Only a few exports use these imports:
Atom.js uses Schema only in searchParam and serializable.
Atom.js uses KeyValueStore only in kvs.
AsyncResult.js uses its four Schema imports only in AsyncResult.Schema.
KeyValueStore.js uses Schema in toSchemaStore.
Behind these edges are 51 modules, 233.3 kB minified:
- Schema side: Schema, SchemaAST, SchemaParser and the other
internal/schema/* modules, plus unstable/http (Headers, Cookies, UrlParams), unstable/net, DateTime, BigDecimal, JsonSchema and JsonPatch.
- Persistence side: KeyValueStore,
unstable/sql (SqlClient, Statement), FileSystem, Path and PlatformError.
What is the expected behavior?
Using Atom.make, or RegistryProvider and useAtom from @effect/atom-react, does not bring Schema or the persistence store into the module graph. They come in with kvs, searchParam, serializable or AsyncResult.Schema.
What do you see instead?
Every import of Atom reaches these 51 modules, and every import of AsyncResult reaches the Schema ones. esbuild removes all of them. Next.js's default production bundler, Turbopack, does not: it keeps modules that only unused exports reach.
We measured a Next.js 16.3.6 app. The figure is the JavaScript a route loads beyond a route whose client component imports nothing from Effect: every chunk the prerendered HTML names, gzip level 9. The esbuild column bundles the same component with React external.
| Client component |
Turbopack (default) |
Turbopack, imports removed |
webpack |
esbuild 0.28.2 |
Atom.make(0) |
63.7 kB |
41.5 kB |
40.9 kB |
25.6 kB |
<RegistryProvider> |
57.7 kB |
40.4 kB |
39.2 kB |
17.3 kB |
<RegistryProvider>, useAtom, Atom.make |
66.6 kB |
44.4 kB |
45.6 kB |
26.3 kB |
"Imports removed" is a copy of the package with four exports taken out: kvs, searchParam, serializable and AsyncResult.Schema, along with their Schema and KeyValueStore imports. Built as published, the copy gives the same Turbopack numbers as the package. With the imports removed, webpack and esbuild give the same numbers as before.
In the Turbopack build of Atom.make(0), 24 of the 51 modules end up in the client bundle (57.4 kB minified).
The third row's client component:
"use client"
import { RegistryProvider, useAtom } from "@effect/atom-react"
import * as Atom from "effect/unstable/reactivity/Atom"
const count = Atom.make(0)
function Counter() {
const [n, setN] = useAtom(count)
return <button onClick={() => setN(n + 1)}>{n}</button>
}
export function Client() {
return <RegistryProvider><Counter /></RegistryProvider>
}
Additional information
Moving kvs, searchParam and serializable into modules of their own, and doing the same for AsyncResult.Schema, would take these imports off Atom and AsyncResult.
Related: #7253 (closed without merging) proposed moving stream and streamResult off the Lifetime prototype. That is a separate case. For Atom.make(0) above, esbuild still keeps 11.4 kB minified of Queue, Channel, PubSub, MutableList, Semaphore, Pull and Stream code.
What version of Effect is running?
effect 4.0.0-rc.117, @effect/atom-react 4.0.0-rc.117 (same imports on main at 330b747)
What steps can reproduce the bug?
A module that only calls
Atom.makehas Schema and the persistence store in its static import graph.mainhas the same imports at 330b747:reactivity/Atom.tsimportsKeyValueStoreandSchema.reactivity/AsyncResult.tsimports four Schema modules.The script below reads esbuild's metafile.
package.json{ "private": true, "type": "module", "dependencies": { "effect": "4.0.0-rc.117" }, "devDependencies": { "esbuild": "0.28.2" } }entry.jschains.mjsOutput (Node 24.19.0, pnpm 12.6.0):
Only a few exports use these imports:
Atom.jsusesSchemaonly insearchParamandserializable.Atom.jsusesKeyValueStoreonly inkvs.AsyncResult.jsuses its four Schema imports only inAsyncResult.Schema.KeyValueStore.jsusesSchemaintoSchemaStore.Behind these edges are 51 modules, 233.3 kB minified:
internal/schema/*modules, plusunstable/http(Headers, Cookies, UrlParams),unstable/net, DateTime, BigDecimal, JsonSchema and JsonPatch.unstable/sql(SqlClient, Statement), FileSystem, Path and PlatformError.What is the expected behavior?
Using
Atom.make, orRegistryProvideranduseAtomfrom@effect/atom-react, does not bring Schema or the persistence store into the module graph. They come in withkvs,searchParam,serializableorAsyncResult.Schema.What do you see instead?
Every import of
Atomreaches these 51 modules, and every import ofAsyncResultreaches the Schema ones. esbuild removes all of them. Next.js's default production bundler, Turbopack, does not: it keeps modules that only unused exports reach.We measured a Next.js 16.3.6 app. The figure is the JavaScript a route loads beyond a route whose client component imports nothing from Effect: every chunk the prerendered HTML names, gzip level 9. The esbuild column bundles the same component with React external.
Atom.make(0)<RegistryProvider><RegistryProvider>,useAtom,Atom.make"Imports removed" is a copy of the package with four exports taken out:
kvs,searchParam,serializableandAsyncResult.Schema, along with theirSchemaandKeyValueStoreimports. Built as published, the copy gives the same Turbopack numbers as the package. With the imports removed, webpack and esbuild give the same numbers as before.In the Turbopack build of
Atom.make(0), 24 of the 51 modules end up in the client bundle (57.4 kB minified).The third row's client component:
Additional information
Moving
kvs,searchParamandserializableinto modules of their own, and doing the same forAsyncResult.Schema, would take these imports offAtomandAsyncResult.Related: #7253 (closed without merging) proposed moving
streamandstreamResultoff theLifetimeprototype. That is a separate case. ForAtom.make(0)above, esbuild still keeps 11.4 kB minified of Queue, Channel, PubSub, MutableList, Semaphore, Pull and Stream code.