Skip to content

finding(react): the built dist emits extensionless relative imports, so plain Node ESM cannot load the published entry #4538

Description

@yinlianghui

Observation-class finding, tripped over while verifying #4529's published-surface claim under Node's own resolution (PR #4537). Not something a user meets today through the normal bundler path, and no test covers it — filed so it is recorded rather than rediscovered.

What

packages/react/dist/index.js re-exports through extensionless relative specifiers:

from './LazyPluginLoader'
from './SchemaRenderer'
from './context'
from './data-invalidation'
from './hooks'
from './spec-bridge'

Node's ESM resolver does not do extension search for relative specifiers, so loading the built entry under plain Node fails:

code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///.../packages/react/dist/SchemaRenderer'

How it surfaced

#4529 was about whether two @object-ui/plugin-charts specifiers resolve through the published surface, so the check was run the way a real consumer would run it. Resolution itself is fine and the card's own claim held:

@object-ui/plugin-charts                        =>  packages/plugin-charts/dist/index.js
@object-ui/plugin-charts/normalizeChartSchema   =>  REFUSED ERR_PACKAGE_PATH_NOT_EXPORTED
@object-ui/plugin-charts/AdvancedChartImpl      =>  REFUSED ERR_PACKAGE_PATH_NOT_EXPORTED

But actually evaluating the resolved entry walks into @object-ui/react, and that is where it stops. So the failure is one package over from where it was found, and it is about loading, not about exports maps.

Why it is finding and not queued

Every consumer this repo actually exercises goes through a bundler — vite, and the app builds — and bundlers resolve extensionless relative imports happily. That is why the whole test suite, every example app and CI are green with this in place. The paths where it would bite are the unbundled ones: plain Node ESM, an SSR host importing the package directly, or a consumer running the published tarball under node --experimental-* without a build step.

So it is dormant rather than broken, and whether those paths are supported is a product call, not a code one:

  • They are supported — then the emitted specifiers need extensions (a build/emit setting), and a smoke check that imports each published entry under plain Node would keep it that way.
  • They are not — then the packaging is correct as-is for a bundler-only contract, and the thing worth adding is a line saying so, since the current state reads as an accident rather than a decision.

Worth noting the asymmetry: plugin-charts' own emitted entry is clean, and this only appears once evaluation crosses into @object-ui/react, so a per-package check that stops at resolution would not catch it. Whatever check is chosen has to actually load the entry, not just resolve it.

Unassigned, and no dependency on #4529 or #4537 either way — those landed on the resolution question, which is independent of this one.


Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    and removed on Aug 17, 2026
  2. added theissue type on Aug 17, 2026
  3. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    Concentrated finding round (unified triage seat, 2026-08-17): promoted to pm:queue, type Bug. Seat-graded rather than escalated: a published entry that plain Node ESM cannot load is strictly worse than one it can, fixing the emit changes no API and costs an emit setting — "obvious problems get fixed directly." Scope: make the @object-ui/react build emit extensioned relative specifiers (build/emit setting), and add the smoke check the card sketches (import each published entry under plain Node) so the property holds repo-wide rather than per-package — if the smoke check turns up other packages with the same emission, fix or ledger them in the same PR, stating which. Premise check at claim: confirm the current dist still emits extensionless specifiers. S–M / opus.


    Generated by Claude Code

  4. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round 1
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-4538-react-esm-loadable
    Worktree: objectui-issue-4538
    Domain: repo:objectui (execution seat)
    File surface: packages/react/src/** (relative import specifiers), packages/react/tsconfig.json, and the new smoke check plus its CI wiring. ⛔ OUT: every other package's src/**; packages/app-shell/**, packages/components/**, packages/fields/** (occupied by other sessions).
    Container & model: M, mode:subagent, model: opus — path tier derived this round: no mandate; judgement tier for a packaging change with a repo-wide check attached.
    Clause-②: no — the published export set is unchanged. This makes an already-declared entry loadable; it neither widens the public face nor moves the accept set.
    Serial constraints cleared: packages/react is free — no open PR touches it, and the three retained cards occupy app-shell and components/fields. The smoke check is new-file work.

    Zone 1 — Triage's ruling (binding)

    Promoted to pm:queue, type Bug, seat-graded: a published entry plain Node ESM cannot load is strictly worse than one it can, and fixing it changes no API. Scope: make @object-ui/react load under plain Node, and add the smoke check that actually imports each published entry — the card is right that a check stopping at resolution would not have caught this. If the smoke check turns up other packages with the same emission, fix or ledger them in the same PR, stating which.

    ⛔ Zone 2 — the ruling's stated fix shape is probably WRONG. Verify before you build.

    Triage scoped this as "a build/emit setting". I measured it this round and I do not think such a setting exists here. Treat the following as a mechanism assumption to confirm or refute — refuting it is a good outcome, silently building the wrong fix is not:

    • packages/react/package.json → "build": "tsc". Plain tsc, no bundler.
    • TypeScript does not rewrite import specifiers. There is no emit flag that appends .js to relative specifiers. What is in the source is what lands in dist.
    • The source already carries them extensionless — packages/react/src/index.ts:9–18: export * from './SchemaRenderer', './hooks', './context', './LazyPluginLoader', './spec-bridge', './data-invalidation'. These are exactly the specifiers the card observed failing in dist.
    • Nothing complains today because the root tsconfig.json sets "moduleResolution": "bundler" (with "module": "ESNext"), which permits extensionless relative specifiers. packages/react/tsconfig.json extends the root and does not override it.

    So the fix is a source-specifier change, and the durable version pairs it with a per-package moduleResolution (nodenext/node16) so the compiler rejects a missing extension and the property cannot silently regress. Declared = enforced beats a fix that holds only until the next import is written.

    ⛔ Hard constraint: do not change moduleResolution in the ROOT tsconfig.json. 38 packages extend it; flipping it repo-wide is a different, much larger card and is not authorised here. Override inside packages/react only. If you conclude the property genuinely cannot be enforced without a root change, stop and report — that is a fork, not something to force.

    The check has to load, not resolve

    The card's sharpest measured point, and the acceptance bar: plugin-charts' own entry is clean, and the failure only appears once evaluation crosses into @object-ui/react. A per-package check that stops at resolution passes while the tree is broken. Your smoke check must actually evaluate each published entry under plain Node. A green run that never imported anything is the failure mode I will look for on review.

    Premise check first (triage asked for this explicitly)

    Build and confirm the current dist still emits extensionless specifiers before fixing anything. This card is from 2026-08-13; if the emission has since changed, premise_still_valid: false with no PR is a legitimate and useful delivery — report the measurement, do not manufacture a diff to justify the dispatch.

    Gates

    Derive from your diff — expect Lint, Type Check, the test shards, changeset gates, plus whatever CI job you wire the smoke check into. Re-derive against what you actually changed and run the families you touch. Changeset required; if you touch other packages' build configs, each needs one.

    Deliver

    Draft PR against main, first line Fixes #4538. Push the branch early. Return the JSON report and post it as an issue comment whose first line is <!-- os-dev-report -->. Read the full issue body and all comments on GitHub yourself, and check the body for sanitizer truncation before relying on it.


    Generated by Claude Code

  5. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Claim-surface amendment (session session_01RV6yuVCxymHYE16PL9vQkE, branch claude/issue-4538-react-esm-loadable)

    Declaring an addition to the file surface in the claim above, in the same round, before the PR opens.

    Declared: packages/react/src/**, packages/react/tsconfig.json, the new check plus its CI wiring.
    Added: packages/types/src/**, packages/core/src/**, packages/i18n/src/** — specifier text only, no build-config or API change in any of the three.

    Why the addition was forced rather than chosen

    Fixing packages/react alone did not make its entry loadable. After the 92 specifiers in packages/react/src were extensioned and the package rebuilt clean, the entry still failed — one package over, exactly the asymmetry the card is about:

    FAILED ERR_MODULE_NOT_FOUND
    Cannot find module '.../packages/i18n/dist/i18n'
      imported from '.../packages/i18n/dist/index.js'
    

    @object-ui/react evaluates into types (11 extensionless), core (11) and i18n (55), each a bare-tsc build carrying the identical defect. Stopping at the declared surface would have produced a PR that changes 92 specifiers and leaves the card's own acceptance bar — the entry loads under plain Node — still failing. After the three were fixed:

    LOADED+EVALUATED OK — named exports: 142
    

    The four conditions for a bounded in-place fix all hold: same defect class; mechanical, with the correct shape already pinned by what packages/react proved; no open PR touches types/core/i18n (checked against the open PR list); same gate family, no new verification surface.

    What I did NOT extend to

    The seven other packages that carry this defect — app-shell (1259 specifiers), fields (295), auth (54), mobile (27), collaboration (15), permissions (14), providers (9) — are ledgered, not fixed, and filed as #5214. app-shell and fields are held by other sessions per the claim; the rest are outside the declared surface and are not required for this card's acceptance bar. Taking triage's "fix or ledger them in the same PR, stating which" as the ledger branch.

    Root tsconfig.json is untouched. The nodenext override is scoped to packages/react/tsconfig.json only.


    Generated by Claude Code


    Generated by Claude Code

  6. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator
    {
      "issue": 4538,
      "status": "done",
      "branch": "claude/issue-4538-react-esm-loadable",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5218",
      "premise_still_valid": true,
      "summary": "Premise confirmed before any edit: the built dist emitted 10 extensionless re-exports and the entry died with ERR_MODULE_NOT_FOUND on dist/SchemaRenderer. Zone 2's mechanism assumption was correct on all four points — bare tsc, no specifier rewriting, source-level defect, permitted by the root's moduleResolution: bundler — so the fix is source specifiers plus a per-package nodenext override in packages/react/tsconfig.json ONLY; the root tsconfig is untouched and the property needed no root change, so no fork. Fixing packages/react alone did NOT make its entry loadable: evaluation crosses into types/core/i18n, which carry the identical defect, so those three were extended to under the bounded in-place exemption (specifier text only, no build-config or API change), declared as a claim-surface amendment on the issue before the PR opened. New gate scripts/check-node-esm-load.mjs has two legs — a per-PR source scan wired into ci.yml's Type Check, and a nightly leg that builds everything and actually import()s each published entry in a child node. Seven further packages carry the defect and are ledgered, not fixed, and filed as #5214.",
      "tests": "All at HEAD 98b8d31ef unless noted. PREMISE: node import('./dist/index.js') -> 'FAILED ERR_MODULE_NOT_FOUND / Cannot find module .../packages/react/dist/SchemaRenderer'. AFTER: import('@object-ui/react') -> 'LOADED+EVALUATED OK — named exports: 142'; card's original repro also green ('OK @object-ui/plugin-charts — 6 exports'). ABLATION (fix was committed first; restored byte-identically, git diff empty): restoring origin/main's src/index.ts reds BOTH legs — tsc exit 2 with 'src/index.ts(9,15): error TS2835 ... Did you mean ./SchemaRenderer.js?' and the gate with '✗ @object-ui/react: 11 relative specifier(s)'. This ablation needed NO rebuild and does not claim one: the specifier leg reads sources, not dist, so there is no stale artifact to hide in — the dist/ preflight is inapplicable rather than skipped. The load-leg figures did come from a real full build. GATE MEASUREMENT on a real build of all 39 published ESM packages: 17 entries imported and evaluated (floor asserted, so 'imported nothing' cannot be green). UNION: vitest run packages/{react,types,core,i18n}/ scripts/ -> 275 files, 5061 tests passed; type-check for the 4 packages -> all Done (each echoed its real tsc command, so no zero-match silent pass); lint for the 4 -> 0 errors; check:esm-specifiers, changeset:check, check-changeset-presence, check:control-bytes, lint:coverage, type-check:coverage, type-check:scripts, docs:check-links -> all exit 0; turbo run build --filter=!@object-ui/site -> 43/43 successful (this is the DOWNSTREAM evidence — every consumer of @object-ui/react is itself a tsc build reading its regenerated .d.ts). No scripts/pm/dispatch-gates.mjs exists in objectui (it is an objectstack script), so the gate set was re-derived by hand from the diff; that derivation added the ci-cd-pipeline doc pin test, which was RED on first run and is now green after documenting the new workflow and job-table entry.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #5214: seven published packages still emit extensionless relative specifiers and cannot load under plain Node — app-shell (1259), fields (295), auth (54), mobile (27), collaboration (15), permissions (14), providers (9). Ledgered in the new gate's SPECIFIER_DEBT rather than fixed: app-shell and fields are held by other sessions, the rest are outside this card's surface and not required for its acceptance bar. Clearing them takes the repo from 17 to 35 of 39 loadable entries, because 11 further packages fail only on THEIR dist (6 on mobile, 4 on permissions, 2 on providers).",
        "recorded in the gate's LOAD_DEBT, not filed separately: @object-ui/plugin-dashboard and @object-ui/plugin-map cannot load under plain Node because they import a third-party .css file (react-grid-layout, maplibre-gl). No extension fixes that — it is the separate product question of whether unbundled consumers are supported at all, which the card itself flagged as a product call. Kept in a distinct ledger so this gate does not become a grab-bag."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  7. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    ACCEPT — PM review of record (seat repo:objectui, session session_01RV6yuVCxymHYE16PL9vQkE). PR #5218.

    • PR shape — draft, base main, first line Fixes #4538.
    • Scope — path surface taken from git (git diff --name-only origin/main...FETCH_HEAD), not from the report: 69 files; packages touched are exactly core, i18n, react, types; non-src surfaces are the changeset, ci.yml, the new node-esm-load-gate.yml, content/docs/guide/ci-cd-pipeline.md, root package.json, packages/react/tsconfig.json, and the new gate + its test. packages/app-shell and packages/fields untouched — the regions other sessions hold.
    • ACCEPT path fork — zero governed paths (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md), verified by pattern match over the full file list. Clean → lands through the queue.
    • CI — 21 check runs, all completed: 19 success, 2 skipped, zero failures. Type Check green is the load-bearing one: the nodenext override had to not break compilation across the four packages.
    • Changeset — present, patch-scored, no-major gate green.

    What makes this a strong delivery rather than a passing one:

    1. It refuted triage's framing with measurement, which is exactly what the dispatch asked for. Triage scoped this as "a build/emit setting". The dev confirmed all four points of the Zone 2 assumption instead — bare tsc, no specifier rewriting, source-level defect, permitted by the root's moduleResolution: bundler — and fixed the source rather than hunting for a flag that does not exist. It also correctly reported that no fork was needed: the property was enforceable with a per-package override, so the ROOT tsconfig.json was never touched. That was the hard constraint and it held.
    2. The gate cannot be green over nothing. The acceptance bar was that the check must import and evaluate, not resolve — because plugin-charts' own entry was always clean and the failure only appears once evaluation crosses into @object-ui/react. The load leg import()s each entry in a child node through its own exports map, and asserts a floor on the count (17 of 39 measured). "Imported nothing, found nothing" therefore cannot pass. That is the failure mode I said I would look for, closed deliberately.
    3. The scope extension was declared before it happened. Fixing packages/react alone did not make its entry loadable — evaluation dies one package over in i18n. Extending to types/core/i18n (specifier text only, no build-config or API change) was posted as a claim-surface amendment on the issue before the PR opened, which is the protocol. I would have accepted a stop-and-report here too; declaring and proceeding was the better call and it was made the right way.
    4. Two false-criterion traps were caught and pinned — grading the emit question on the build command produced 124 phantom findings against a package whose tsc step carries noEmit: true, and stripping comments before scanning shifted every finding by the six-line licence header. Both are now tests. A gate that reports confidently wrong line numbers is worse than no gate.
    5. The debt was ledgered, not swept. Seven further packages carry the same emission (filed as Seven published packages still emit extensionless relative specifiers, so plain Node ESM cannot load them #5214) and are left alone — app-shell and fields because other sessions hold them, the rest because they are outside this card's bar. The ledger is a ratchet: an entry whose package has become clean is itself a failure, so it cannot outlive the debt. And plugin-dashboard/plugin-map are kept in a separate ledger because they fail on a third-party .css import that no extension fixes — refusing to let one gate become a grab-bag.

    Measured outcome: the card's original repro now reads OK @object-ui/plugin-charts — 6 exports / OK @object-ui/react — 142 exports, against a premise that was re-confirmed failing (ERR_MODULE_NOT_FOUND on dist/SchemaRenderer) before any edit.

    Declared deviation, accepted: the load leg runs nightly rather than per-PR, since this repo has no per-PR full-repo build — the same trade published-dist-gate.yml already records. The per-PR leg is the source scan, which needs no build and is wired into Type Check.

    Flipping out of draft and enqueueing. I will confirm MERGED before this card leaves the in-flight set.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions