Skip to content

fix(app-shell): explicit extensions on every relative specifier, so plain Node can load the published entry - #5383

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5357-app-shell-esm-specifiers
Aug 20, 2026
Merged

os-support-ai merged 2 commits into
mainfrom
claude/issue-5357-app-shell-esm-specifiers

Conversation

@os-support-ai

@os-support-ai os-support-ai commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #5357

Node's ESM resolver does not extension-search relative specifiers and tsc never rewrites them, so an extensionless ./Foo in the source stayed extensionless in dist. Everything this repository exercises goes through a bundler, which is why it was green.

Scale, honestly framed. This is the largest package in the family and the smallest incremental unlock in it. The load leg does not move (see below); what moves is that a published entry stops carrying the defect, and that the ledger guarding the defect class becomes empty.

What changed

1271 relative specifiers across 275 files now carry the extension they emit as. Plus four consumers of that convention, listed under "Beyond the card's file surface".

How the specifiers were chosen — parser, then filesystem

Two decisions, neither made by pattern:

  • Which strings are specifiers: the TypeScript parser. ts.createSourceFile plus a walk over ImportDeclaration / ExportDeclaration / ImportTypeNode / dynamic-import call nodes. Prose is trivia to a parser, so a specifier living in a comment is not a question.
  • Which extension to append: the filesystem. Candidates are stat-ed in the same order the gate's own resolvesToModule uses, and the suffix follows whichever candidate answered — ./x becomes ./x.js only because x.tsx exists, ./x/index.js only because x/index.ts does. 0 specifiers resolved to nothing; 0 already carried an extension.

Three strings in this package parse as imports but live in prose comments. They were left alone:

src/views/metadata-admin/i18n.ts:17                  './i18n'        (a JSDoc "Usage:" example)
src/views/metadata-admin/inspectors/unique-id.ts:7   './_shared'     (prose about a re-export)
src/preview/DraftChangesPanel.tsx:52                 NOT prose — see below

The parser and the gate disagree by exactly one, and the gate is wrong

withoutCommentedCode() blanks block comments with a regex that is not line-comment aware. DraftChangesPanel.tsx:44 is a line comment naming a package glob:

// `vendor-objectstack` chunk group claims every `@objectstack/*` module except

The /* in that glob opens a fake block comment that runs to the next close, around line 55 — blanking the live import { diffFields } from '../views/metadata-admin/previews/object-fields-io' on line 52.

method relative specifiers over the same 424 emitted sources
the gate's regex over withoutCommentedCode(source) 1270
the TypeScript parser 1271

Set difference: exactly one, one direction, and it is real code. The gate produced zero false positives. This PR corrects the specifier itself (app-shell source, in scope) but leaves the gate alone — filed as #5382, sibling of #5367. Had the sweep trusted the gate, dist would have shipped one extensionless import and check:esm-specifiers would have called it clean.

The diff, graded mechanically

A sweep this size is unreviewable by eye and stays green either way, so the diff was checked by a script that never consults the one that produced it — it reads git diff -U0, pairs each removed line with its added line, and requires the pair be byte-identical once every quoted string is blanked:

hunks:                  454
hunks with unequal -/+: 0
changed line pairs:     1271
NON-CONFORMING:         0
append histogram:       {".js": 1227, "/index.js": 44}

git diff --stat agrees: 1271 insertions, 1271 deletions. No line added, moved or dropped.

The ledger: emptied, and what that does

@object-ui/app-shell was the last name in SPECIFIER_DEBT, so deleting it leaves new Map(). Checked rather than assumed, because the card asked:

  • The gate: every use is .has() or iteration, both fine on an empty map. The behavioural change is the point — the specifier leg stops being a ratchet with grandfathered names and becomes a hard requirement.
  • The test (scripts/__tests__/check-node-esm-load.test.ts): 28 tests, all pass. Two become vacuous (the per-entry reason loop, and the disjointness loop reading SPECIFIER_DEBT) — worth knowing, not worth blocking on.
  • Types: measured both ways. An unannotated new Map() resolves to the Map(any, any) overload in lib.es2015.collection.d.ts, so pnpm type-check:scripts is green with or without an annotation. A @type annotation was added anyway, since inference now has nothing to work from.

The load leg does not move — the second failure was masked

The card predicted 34 of 39 becoming 35. Measured, it stays 34. The specifier defect is gone; app-shell's first error simply moved past it:

before:  ERR_MODULE_NOT_FOUND        dist/components/AppShell
after:   ERR_UNKNOWN_FILE_EXTENSION  react-grid-layout/css/styles.css

DashboardView.tsx and ReportView.tsx import @object-ui/plugin-dashboard statically, and its module-scope stylesheet is something Node cannot load at all. That is LOAD_DEBT's question, not this defect class — it is the exact first-failure shape #5214 measured when it predicted 5 red and observed 10.

So @object-ui/app-shell moves from SPECIFIER_DEBT to LOAD_DEBT, beside the package the failure comes from, and clears the moment @object-ui/plugin-dashboard does. This is the one judgement call in the PR and it is one line to revert. The alternative was to leave the load leg red — and since this PR touches scripts/check-node-esm-load.mjs, that workflow runs on merge to main, so leaving it red means landing a knowingly-red alarm.

Two consequences worth stating plainly:

  • The card's tally changes: the remaining set is 5, not 4 — app-shell, plugin-dashboard, plugin-map (all .css), create-plugin, runner (not importable by design). Every one is ledgered and explained; none is a specifier defect.
  • LOAD_DEBT matches by package name, not by error code, so the load leg alone would now also silence a genuine specifier regression in app-shell. The specifier leg — a hard requirement as of this PR — is what guards the class, and the ablation below shows it doing so.

The CI failure this PR hit, and what it actually revealed

The first push was red on Test (shard 3/4): examples/console-starter/test/vite-alias-closure.test.ts, expected 401 to be greater than 500. Reproduced locally and traced before touching anything, and the cause is broader than this PR:

That suite walks the example's import graph to prove the Vite alias table covers it. Its resolveModule() probes candidate extensions but had no notion that ./Foo.js addresses Foo.tsx. So every package converted to explicit extensions became partially unwalkable — the walk follows the package entry, then fails on each ./X.js re-export inside it and stops. Measured with the walker's own logic:

tree filesWalked relative specifiers dropped packages reached
main before this PR (as shipped) 890 236 29
this branch (as shipped) 401 275 22
either tree, resolver repaired 1242 0 29

So this has been landing green since #4538 and was made worse by #5214's six packages; main today walks 890 of 1242 files and nobody could tell. app-shell converting is what finally crossed the floor.

Two things follow, and neither is "lower the floor":

  • The floor stays at 500 and the assertion is untouched. The repaired walk gives 1242, so it clears comfortably. The floor is worth more now, not less — its previous headroom was masking the drift. Whether to raise it from 500 given a true reading of 1242 is a judgement on a guard this PR does not own; recorded here for routing rather than acted on.
  • closure.unresolvable never had a chance. The relative branch drops a miss with no record (if (resolved) walk(resolved); continue;), so that assertion is structurally blind to this whole class and would have passed at 401. Only the floor could see it. That reporting gap is why two earlier PRs landed the damage green, and it is filed as The alias-closure walker drops an unresolvable RELATIVE specifier with no record, so closure.unresolvable is structurally blind to it and only the filesWalked floor can see the damage #5386 rather than changed here — recording relative misses naively would fail on a legitimate ./x.css import, so it needs a deliberate shape.

Repaired, main and this branch walk an identical 1242 files and reach the same 29 packages with 0 missing alias entries — so the truncated walk was not hiding a second defect, and this PR changes the graph's spelling, not its structure.

Step 2 (the nodenext pin) is unavailable here, as the card warned

Applied and measured, then reverted. tsc under the pin: 1097 errors — 880 TS2305, 215 TS7006 (fallout from the unresolved imports), 2 TS2345 — across 162 files.

TS2305 by module:   864  '@object-ui/components'      (21 extensionless re-exports in its dist/index.d.ts)
                     16  '@object-ui/layout'          (7 of them)

packages/app-shell/tsconfig.json is therefore byte-identical to main. That is #5365's territory. It named components; layout is a second package in the same shape, measured here and added to #5365 as a comment rather than split onto a card of its own. The root tsconfig.json was not touched.

For the other side of that question: app-shell builds with a bare emitting tsc, so this source fix reaches its typings for free — dist/**/*.d.ts now carries 332 relative specifiers, all with explicit extensions.

Beyond the card's file surface

The card scoped this to packages/app-shell/src/**, packages/app-shell/tsconfig.json, and the SPECIFIER_DEBT entry. Four files outside that were changed, each forced mechanically by the convention this PR applies, each named here for review:

  1. scripts/check-i18n-call-site-keys.mjs — resolveImport() did not know that ./i18n.js addresses i18n.ts, so app-shell's registered local translator resolved to a path with no file behind it and pnpm check:i18n-keys went red, reporting the whole metadata-admin table as unregistered-translator. The specifier was right and the resolver was wrong. Fixed by stripping the emitted extension before the candidate walk, source spelling first so a package that really ships a .js still resolves to it.
  2. scripts/__tests__/check-i18n-call-site-keys.test.ts — a pin for the above, so it cannot regress silently.
  3. packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx — a source-text ratchet reading packages/app-shell/src/console/marketplace/PluginDisclosure.tsx and matching its import line. Spelling updated to the source; the assertion's subject is unchanged, and it pins the explicit form rather than being made tolerant of both.
  4. examples/console-starter/test/vite-alias-closure.test.ts — the CI failure above. Same defect as (1) in a second resolver. Written as a pure fallback after every existing candidate has failed, so a package that genuinely ships a .js beside its TypeScript still resolves to that file and any specifier that already resolved returns the same answer; behaviour for extensionless specifiers is unchanged by construction.

The blind spot in how (1)–(3) were found, and the sweep that closes it

The first three came from enumerating this change's consumption radius by path: the 625 distinct rewritten specifiers searched, plain and regex-escaped, across all 66 files repo-wide that address packages/app-shell/src by path. That method cannot see (4), which names no app-shell path at all and reaches it transitively through an alias table with its own resolver.

So the radius was re-enumerated by behaviour instead — every file that both extracts import specifiers programmatically and resolves them by probing candidate extensions. Six members repo-wide:

file status
examples/console-starter/test/vite-alias-closure.test.ts affected, fixed here (was silent)
scripts/check-i18n-call-site-keys.mjs affected, fixed here (was loud)
scripts/check-node-esm-load.mjs immune by construction — an explicit extension short-circuits before resolvesToModule
scripts/__tests__/helpers/config-program.ts out of app-shell's scope, and throws on an unresolvable specifier rather than skipping it
scripts/__tests__/side-effects-declaration-consistency.test.ts parses alias tables and entry forms; resolves no arbitrary relative specifier
packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts scoped to i18n's own locale sources

Two near-misses checked and cleared: apps/console/vite.config.ts (that is Vite's own resolve.extensions, and Vite handles the convention natively) and packages/cli/src/utils/workspace-vite.ts (probes index.{ext} inside a src dir, never a specifier).

The property that separates the two affected members is worth naming: loud versus silent on a miss. (1) turned a gate red within minutes. (4) had been degrading through two prior PRs. That is the argument in #5386.

Verification

Everything below is at b5ded7294, on a full build in a dedicated worktree, run from the repository root with path filters — never pnpm --filter (pkg) test.

Whether a build artifact sits between the edit and the thing under test, per leg:

  • Yes for both check-node-esm-load legs — they read dist/. So the emitted specifier was read out of dist/ before running the gate every time. Marker: the one specifier the gate cannot see, dist/preview/DraftChangesPanel.js:43.
  • No for vite-alias-closure — it reads packages/*/src, examples/console-starter/src and vite.config.ts, all sources. Nothing is built between the edit and the assertion, which is why its ablation is a plain re-run.
leg dist/preview/DraftChangesPanel.js:43 whole dist
before '../views/…/object-fields-io' 1127 relative, 1127 extensionless in 260 of 424 files
after '../views/…/object-fields-io.js' 1127 relative, 1125 .js, 2 bare
ablated '../views/…/object-fields-io' back to 1127 extensionless
restored '../views/…/object-fields-io.js' 1125 .js, 2 bare

The 2 bare ones after the fix are the JSDoc prose above, which tsc copies into the emit. Neither is code and Node never resolves them.

Gate readings

before after
pnpm check:esm-specifiers exit 0 — ledgered @object-ui/app-shell: 1270 extensionless exit 0 — no findings, ledger empty
pnpm check:node-esm-load exit 0 — 34 of 39, app-shell ERR_MODULE_NOT_FOUND exit 0 — 34 of 39, app-shell ledgered .css

My own count with the gate's method is 1270; the parser's is 1271. The card's three prior numbers (1259 / 1271 / 1813) came from different methods at different times and are not evidence of anything here.

Ablations, each with its prediction recorded first

Specifier sweep — revert packages/app-shell/src to origin/main, keep the emptied ledger, rebuild, read dist back, run both legs.

predicted observed
packages red on the specifier leg 1 1
findings printed 1270, not 1271 — the gate is blind to DraftChangesPanel.tsx:52 1270
artifact reverted in dist yes yes
load leg treats app-shell as ledgered yes, LOAD_DEBT matches by name yes
exit code of the full run 0 1

The one gap is the last row, and it is my error rather than a finding: full mode runs the specifier leg first, which had already set the failure flag. The mechanism predicted for the load leg was correct — it printed ledgered for a genuine specifier regression, which is exactly why the note above says the specifier leg is now the one carrying the guard. Both ablation legs were rebuilt and both artifacts read back, so no mutated build output survived into the final measurements.

Alias-closure resolver — revert the fallback alone. Predicted: the suite reproduces CI exactly, one test failing at 401 against the floor, the other four passing. Observed: AssertionError: expected 401 to be greater than 500, 1 failed | 4 passed. Restored: 5 passed, and the walk reports filesWalked: 1242, unresolvable: 0, 29 packages, 0 missing aliases.

Suites and gates

  • Full suite, all four CI shards, at b5ded7294: 1722 test files, 21693 passed, 1 skipped, 0 failed. (1/4 431 files / 5493; 2/4 431 / 5639; 3/4 430 / 5504 — the shard that was red; 4/4 430 / 5057.)
  • pnpm --filter @object-ui/app-shell type-check — clean (tsc --noEmit && tsc -p tsconfig.test.json, both echoed).
  • pnpm --filter '...@object-ui/app-shell' type-check — downstream direction (console, byo-backend-console, console-starter), 4 projects in scope, 0 errors.
  • lint on app-shell + i18n — 0 errors (pre-existing warnings only).
  • 15 repo gates re-run individually on b5ded7294, all PASS: lint:coverage, type-check:coverage, check:spec-symbols, check:action-forward-parity, check:phantom-deps, check:self-import, check:esm-specifiers, check:control-bytes, check:published-dist, check:i18n-keys, check:i18n-drift, check:i18n-dead-keys, check:skills-paths, check:doc-types, check:doc-snippets. Plus changeset:check.
  • No control bytes in any changed file.

Landing

Changeset is patch (@object-ui/app-shell). No major, per the 2026-08-19 ruling. content/docs/releases/ untouched.

Observation on #5214, for the maintainer to act on — not acted on here

#5214 has no outstanding piece left after this. Its PR5369 landed as Part of, so it stayed open pending app-shell, and the SPECIFIER_DEBT string still naming objectui#5214 is the line this commit deletes. All seven packages it listed are done. Two things to weigh before retiring it: the specifier leg it created is now a hard requirement with an empty ledger, which is the state it was aiming at; but #5365, #5382 and #5386 were all split out of that work and remain open. Recorded as an observation only — no state changed on that card from here.

Filed along the way, unassigned: #5382 (the gate's comment mask) and #5386 (the alias walker's silent relative-miss). Added to #5365: @object-ui/layout measured as a second package in that card's shape.

Generated by Claude Code

…lain Node can load the entry (#5357)

Node's ESM resolver does not extension-search relative specifiers and `tsc`
never rewrites them, so an extensionless `./Foo` in the source stayed
extensionless in `dist` and `import('@object-ui/app-shell')` under plain Node
died with ERR_MODULE_NOT_FOUND on the package's own first re-export. Every
consumer this repository exercises goes through a bundler, which is why it was
green.

1271 relative specifiers now carry the extension they emit as (1227 `.js`,
44 `/index.js`). Every one was identified by the TypeScript PARSER and resolved
against the FILESYSTEM: the suffix follows whichever candidate file exists,
never the shape of the string. Three strings that parse as imports live in
prose comments and were left alone.

The parser was used rather than the gate's own regex because the two disagree:
`withoutCommentedCode` strips block comments with a pattern that is not
line-comment aware, so a back-ticked `@objectstack/*` inside a `//` line opens
a block comment it runs to the next close, swallowing two live `import` lines
in DraftChangesPanel.tsx. Parser 1271, gate regex 1270, and the one the gate
cannot see is real code. Filed separately; not fixed here.

`@object-ui/app-shell` was the last name in SPECIFIER_DEBT, so deleting it
empties the map: the specifier leg stops being a ratchet with grandfathered
names and becomes a hard requirement.

The entry still does not evaluate under plain Node, for a different reason that
the specifier error was masking: it statically imports @object-ui/plugin-dashboard,
whose module-scope react-grid-layout stylesheet Node cannot load at all. That is
LOAD_DEBT's class, not this one, and it is recorded there beside the package it
comes from.

Two consumers outside the package needed the same convention applied:
`check-i18n-call-site-keys.mjs` resolved `./i18n.js` to a path with no file
behind it and reported the whole metadata-admin table as an unregistered
translator, and one source-text pin in @object-ui/i18n spelled the old
specifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions github-actions Bot added the tests label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 25.3 KB 350 KB
Entry file index-BInyohVQ.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.94KB 113.61KB
core (index.js) 4.11KB 1.62KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.34KB
fields (index.js) 237.07KB 59.46KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.42KB 1.39KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 30.51KB 7.57KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 128.07KB 32.77KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 241.46KB 60.56KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 124.19KB 30.20KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.30KB 53.06KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.66KB 27.13KB
plugin-map (index.js) 20.08KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.52KB 20.67KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 36.10KB 12.26KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

…js` specifier to its source (#5357)

`resolveModule()` probed candidate extensions but had no notion that `./Foo.js`
is how a package whose build preserves specifiers has to spell `Foo.tsx`. So
every converted package became partially unwalkable: the walk followed the
package entry, then failed on each `./X.js` re-export inside it and stopped.

The relative branch of the walk drops an unresolvable specifier with NO record
— `if (resolved) walk(resolved); continue;` — so `closure.unresolvable` could
never see this class and stayed empty. The only assertion that could catch it
was the `filesWalked` floor, and only once enough of the graph had gone.

Measured with the walker's own logic:

  origin/main, as shipped   890 files walked, 236 relative specifiers dropped
  this branch, as shipped   401 files walked, 275 dropped   (below the 500 floor)
  either tree, repaired    1242 files walked,   0 dropped

So this was landing green since objectui#4538 and continued through the six
packages of objectui#5214; app-shell converting is what finally crossed the
floor. The floor did its job — two PRs late, because the direct symptom was
being swallowed.

The lookup is a pure FALLBACK after every existing candidate has failed, so a
package that genuinely ships a `.js` beside its TypeScript still resolves to
that file, and any specifier that already resolved returns the same answer.

Repaired, both trees walk an identical 1242 files and reach the same 29
packages with 0 missing alias entries — the alias table was already complete,
so the truncated walk was not hiding a second defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE

Copy link
Copy Markdown
Collaborator Author

Not accepted — CI is red, and it is a real regression from this PR. PM round 19, session session_01RV6yuVCxymHYE16PL9vQkE. Sent back to the implementing dev; ⛔ no code touched from this seat.

FAIL examples/console-starter/test/vite-alias-closure.test.ts
     > console-starter vite alias table > reaches the workspace graph it is meant to cover
AssertionError: expected 401 to be greater than 500
  ❯ examples/console-starter/test/vite-alias-closure.test.ts:154:33

Test (shard 3/4), head 4cd8c01f1: 1 failed / 5524 passed. ⛔ Not flaky and ⛔ not a re-run candidate — the test body ran and 430 other files in the same shard passed.

The failing assertion is the one designed for exactly this

Its own preceding comment: "Same guard for the walker: a resolution regression that walked … would make the closure assertion trivially true." The floor exists to catch a walker that quietly stopped resolving, and filesWalked collapsing from >500 to 401 is that. It did its job.

Diagnosis, offered to the dev to verify rather than adopt

This appears to be the same defect class this PR already found and fixed once: scripts/check-i18n-call-site-keys.mjs's resolveImport() did not know ./i18n.js addresses i18n.ts. Here a second consumer walks the workspace graph through the console-starter alias table, meets the now-explicit ./x.js specifiers, cannot resolve them to x.tsx, and truncates the walk.

⚠️ Line 155 (expect(closure.unresolvable).toEqual([])) never executed — the throw came first — so whether that is also non-empty is unknown and may be the more direct symptom.

The interesting part is why the sweep missed it

The PR enumerated its consumption radius mechanically and well: 625 rewritten specifiers searched, plain and regex-escaped, across all 66 files repo-wide that address packages/app-shell/src by path. That method is sound and it found two real pins.

But this consumer names no app-shell path. It reaches app-shell transitively, through the alias table, via its own resolver. So the blind spot has a shape: a resolver that walks the graph, versus a file that mentions it. I have asked for that class to be enumerated before the fix lands, because one such consumer existing means the class is real.

That is a correction to the method, not a criticism of it — mechanical radius enumeration is still far better than waiting for red, and it is what caught the two check-i18n-call-site-keys and packages/i18n regressions before CI ever saw them.

Constraints given

⛔ Do not lower the 500 floor and do not relax the assertion — weakening a gate to get green is out of bounds. If the true post-fix count is legitimately different, report the measurement and route it rather than adjusting it.

Everything else in my review stands: surface verified against the declared set plus three declared breaches, tsconfig.json byte-identical, SPECIFIER_DEBT genuinely new Map(), and the LOAD_DEBT move accepted as the right bookkeeping. This is a fourth forced out-of-surface file, to be declared with the others.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 25.3 KB 350 KB
Entry file index-BInyohVQ.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.94KB 113.61KB
core (index.js) 4.11KB 1.62KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.34KB
fields (index.js) 237.07KB 59.46KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.42KB 1.39KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 30.51KB 7.57KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 128.07KB 32.77KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 241.46KB 60.56KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 124.19KB 30.20KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.30KB 53.06KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.66KB 27.13KB
plugin-map (index.js) 20.08KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.52KB 20.67KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 36.10KB 12.26KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-ai
os-support-ai marked this pull request as ready for review August 20, 2026 04:51
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 20, 2026
Merged via the queue into main with commit 0fd1144 Aug 20, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5357-app-shell-esm-specifiers branch August 20, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@object-ui/app-shell emits 1259 extensionless relative specifiers, so plain Node ESM cannot load its published entry

2 participants