Skip to content

Atom and AsyncResult import Schema and KeyValueStore statically for kvs, searchParam, serializable and AsyncResult.Schema #8523

Description

@sashkode

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions