Skip to content

perf(sdk): stop shipping unused code to browser SDK consumers (#37571) - #37582

Open
fmontes wants to merge 12 commits into
mainfrom
sdk-components
Open

fmontes wants to merge 12 commits into
mainfrom
sdk-components

Conversation

@fmontes

@fmontes fmontes commented Sep 16, 2026

Copy link
Copy Markdown
Member

Fixes #37571

The problem

  • The component map ships everything. Static imports make every mapped content type reachable from the client entry, so a page downloads all of them to render three. The reporting customer has 135.
  • TinyMCE ships to every page. DotCMSEditableText imported it at top level, but only renders an editor in UVE edit mode — published pages carried it for nothing. One hook import cost ~10 KB gzip.
  • No package declared sideEffects. Without it bundlers must assume every module matters, so importing one export from the barrel kept the whole barrel. Tree-shaking couldn't work regardless of how the code was written.
  • Contentlet repeated work per instance. Each registered its own dotcms:analytics:ready listener, called getUVEState(), and ran getBoundingClientRect() — a forced reflow feeding only the dev-mode placeholder, so production computed and discarded it. 60 contentlets, 60 times.

What changed

  • TinyMCE split into TinyMCEEditor.tsx, loaded via lazy() on entering edit mode. Complements sideEffects: that handles consumers who never use editable text, this handles the many who do but serve published pages.
  • sideEffects declared. false for client/uve/types. React gets an allow-list instead — its CSS modules inject styles on import, and a blanket false would drop the grid silently. A test asserts those stylesheets survive.
  • DotCMSPageProvider resolves isDevMode and isAnalyticsActive once and shares them via context; useCheckVisibleContent is gated behind dev mode. One listener and one UVE lookup per page instead of per contentlet, and no reflow in production. Memoizing the context value also required hoisting DotCMSLayoutBody's default props — inline {} defeated it.
  • Contentlet provides a Suspense boundary. React.lazy suspends and throws without one; next/dynamic brings its own, plain lazy doesn't. Consumers can now map lazy components in any framework with no extra wiring — this is what makes the Astro example work.
  • @dotcms/analytics root is framework-neutral. It was export * from './lib/react', so any import pulled React and next/navigation.
  • Examples map content types with next/dynamic / React.lazy, and the READMEs explain why — copying the old snippet is how this reached production.

Before and after

Against published 26.9.14-1, gzip:

Import Before After
DotCMSLayoutBody 11.3 KB, with TinyMCE 6.1 KB, none
useEditableDotCMSPage 10.2 KB, with TinyMCE 4.2 KB, none
Analytics root 23.3 KB, pulls React 20.8 KB, no React

Matches the audit in the issue, so we measured the same thing.

Runtime, 10 contentlets: analytics listeners 10 → 1, getBoundingClientRect() in production 10 → 0.

Component map — examples/nextjs, its real 14 content types, same components both ways, measured on the main content route:

Map /[[...slug]] initial JS On-demand chunks
Static imports 619.7 KB raw / 181.3 KB gzip 6
next/dynamic 594.7 KB raw / 172.7 KB gzip 20

−25 KB raw / −8.6 KB gzip on a site with 14 content types. Scaled to 135, the same conversion moves that route 710.5 → 615.7 KB raw and 189.5 → 177.8 KB gzip, with 141 on-demand chunks instead of 6.

The gzip deltas are modest because the framework shell dominates a small example. The structural change is the durable part: every mapped component leaves the initial download, so the saving grows with how heavy the components actually are — and real component libraries are heavier than this example's.

What this does not fix

Upgrading won't make an existing consumer's map lazy — it's their code, resolved at their build time. One line per content type; the React README has the migration and two traps: a barrel re-exporting content types undoes it, and customRenderers has the same shape. Either next/dynamic or React.lazy works.

examples/nextjs ships npm run analyze so a consumer can check their own build:

JavaScript downloaded before any interaction, per route:

  /[[...slug]]/page              10 chunks    594.7 KB raw    172.7 KB gzip
  /blog/page                     11 chunks    609.7 KB raw    178.6 KB gzip
  /blog/post/[[...slug]]/page    10 chunks    596.3 KB raw    173.2 KB gzip

  20 further chunks load on demand.

Pass a string from a component's output and it reports whether that code is downloaded up front or deferred. There is no built-in alternative: Turbopack prints no First Load JS column, --experimental-analyze never names components, and @next/bundle-analyzer is a webpack plugin that emits nothing on a Turbopack build. All three were tried.

A publishing bug

React's CSS modules inject via a style-inject helper. With preserveModules, rollup copied that helper into dist/libs/sdk/react/node_modules/.pnpm/… and pointed the import there. Resolves locally, but npm strips node_modules from the tarball — published stylesheet chunks would have imported a missing file and thrown for every consumer. Injection is now emitted inline.

Validation

publint runs against every built package. It checks what a bundle probe can't see — exports resolution, emitted files, module type — and found two pre-existing defects on its first run, both fixed here:

  • @dotcms/analytics shipped ESM with no "type": "module". Node reparsed every file on import ([MODULE_TYPELESS_PACKAGE_JSON]); older Node fails outright.
  • types was shadowed in the generated exports maps. Conditions match in declaration order and @nx/rollup writes module first, so TypeScript can resolve to JavaScript instead of declarations. tools/rollup/types-first.cjs reorders the key — targets untouched.

The gate fails on errors and warnings, with an explicit accept-list. That matters: the analytics defect was a warning, so an errors-only gate would have missed the very bug that justified the tool.

sdk-bundle-budgets keeps only what publint can't do — proving a named module is absent from a bundle. A layout-only import must contain no TinyMCE, editable text or block-editor renderers; a hook-only import no layout modules or CSS; neutral analytics no React or Next. Plus gzip budgets in budgets.json.

A size budget is not a substitute for that. Removing sideEffects from the built @dotcms/react and measuring with size-limit moved DotCMSLayoutBody 4.3 → 4.3 KB and useEditableDotCMSPage 4.0 → 4.6 KB under rolldown, 4.25 → 4.25 and 3.96 → 4.55 under esbuild. Every configuration passed its budget. The named assertion fails and says which modules came back.

Module lists come from esbuild's metafile filtered to bytes actually contributed — the raw input list includes everything a barrel made it parse and then tree-shake, which would report TinyMCE as present in a bundle shipping none.

examples/scripts/check-initial-bundle.mjs resolves a route's real initial chunks (Next client-reference manifests; for Astro, no static importer and reached via import()) and proves an UnusedComponentProbe and the editor are absent.

sdk-bundle-budgets and the publint gate run in the existing frontend CI job. The example check does not — CI never builds the examples — so it is a local check, run when changing a component map or how the SDK is bundled. Every check was verified by breaking what it protects.

Breaking change

@dotcms/analytics root is now neutral. Importing useContentAnalytics or DotContentAnalytics from root needs /react — the subpath already existed and is what the README shows, so only undocumented usage breaks. MIGRATION.md included; package is 0.0.1-beta, unused in this repo.

Also fixed, pre-existing

  • nx build sdk-react didn't work at all. @nx/rollup 23's postcss filter never matches absolute module ids, so rollup parsed Column.module.css as JavaScript. Only SDK with CSS.
  • examples/astro had 14 type errors and failed its own build, reproduced against published packages. Fixed, since it now has to build to be verified.

Not included

@dotcms/client/page, /navigation, /content, /ai — built, measured at 3–5 KB gzip each, removed. No example used them, and the saving only lands if you avoid sharing a client instance, which the README recommends doing. Four entrypoints to support forever with nothing demonstrating them. That acceptance criterion is deliberately unmet.

Needs a human

Offline checks only, so unproven:

  • A lazily-mapped component is fetched and rendered when its content type appears.
  • Edit mode loads the editor; inline editing works.
  • Live rendering, client-side navigation, SSR, block editor, analytics, fallbacks.

Untouched: examples/angular and angular-ssr don't build before or after — published @dotcms/angular doesn't match their pinned Angular. Needs its own issue.

🤖 Generated with Claude Code

fmontes and others added 6 commits September 16, 2026 09:39
…itions

Part of #37571.

React runtime
- DotCMSEditableText now loads @tinymce/tinymce-react through a dynamic import
  (new TinyMCEEditor module) behind a Suspense boundary, so live-mode consumers
  no longer download the editor they can never open.
- Dev mode and the Analytics-ready flag are resolved once per layout tree in
  DotCMSPageProvider and shared through the page context. Previously every
  contentlet registered its own dotcms:analytics:ready window listener and ran
  its own UVE state lookup.
- useCheckVisibleContent is gated behind the dev-mode flag. Its
  getBoundingClientRect() forces a synchronous layout and only feeds the editor's
  empty-contentlet placeholder, so production stopped paying for it.
- The page context value is memoized, and Contentlet wraps its mapped component
  in Suspense so consumers can map content types to React.lazy/next/dynamic
  components without supplying their own boundary.

Measured on a 10-contentlet page: 10 analytics listeners and 10
getBoundingClientRect() calls in production, down to 1 and 0. Covered by the new
DotCMSLayoutBody.runtime.test.tsx, which fails against the previous code.

Packaging
- @nx/rollup generates "import": "./x.cjs.mjs", an interop bridge backed by a
  single non-analysable CommonJS file, and every modern bundler resolves import
  before module. A shared writeBundle plugin now rewrites the generated map so
  import resolves to the real ESM artifact and require keeps pointing at the
  CommonJS build, with types first so it is not shadowed. Applied to client, uve
  and types, which also gain sideEffects: false.
- @dotcms/react declares sideEffects as an allow-list rather than false, because
  its CSS is injected by JavaScript and a blanket false would let bundlers drop
  the grid styles.

Build fixes found on the way
- nx build sdk-react could not complete at all: @nx/rollup 23 replaced the
  postcss file filter with a picomatch matcher that never matches the absolute
  module ids rollup passes, so stylesheets reached rollup untransformed and it
  failed parsing Column.module.css as JavaScript. Swapped in
  rollup-plugin-postcss, already a workspace dependency.
- Style injection is emitted inline instead of importing style-inject, which
  rollup was copying into a node_modules directory inside the package. npm
  always strips node_modules from the tarball, so published CSS chunks would
  have imported a file that did not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s root

Part of #37571.

@dotcms/client — additive subpath entrypoints
- createDotCMSClient constructs page, navigation, content and AI in its
  constructor, so every API is statically reachable and no amount of
  sideEffects metadata removes them from a page-only bundle.
- Adds @dotcms/client/page, /navigation, /content and /ai, each exporting a
  focused factory. Verified against the built output: the closure of
  @dotcms/client/page is page-api + client-context + internal, with no
  content-api (55KB) and no ai-api (10.6KB).
- Validation, URL normalization and the Authorization header move to a shared
  createClientContext so the root factory and the subpaths behave identically,
  down to their error messages. createDotCMSClient and its .page/.nav/.content/
  .ai properties are unchanged; all 300 client tests pass untouched.

@dotcms/analytics — framework-neutral root (breaking)
- src/index.ts was `export * from './lib/react'`, so the root and ./react were
  the same module and importing the package pulled in react, next/navigation and
  @dotcms/uve. The ~4,300-line neutral engine had no entrypoint of its own.
- The root now exports initializeContentAnalytics, getAnalyticsConfig and the
  shared types. Confirmed against the built artifact: the root's transitive
  closure has no react and no next/*. React bindings stay at ./react, which
  already existed and is what the README has always documented.
- Declares next as an optional peer dependency: ./react has always imported
  next/navigation without saying so.
- Fixes the exports map, which listed `types` after `import` (resolvers take the
  first match, so the types condition was unreachable), and the typesVersions
  entry that disagreed with it.
- MIGRATION.md and a README entrypoint table cover the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds sdk-bundle-budgets, which runs after the SDKs are built, bundles a one-line
import of each public entry point with esbuild, and asserts on what actually
ships. Unit tests cannot see any of this — it is a property of the built package
and its exports map, not of the source.

Probes and what each forbids are declared in src/probes.ts; gzip ceilings live in
budgets.json so a change to one is visible in review.

Current numbers, against the issue's measured baseline:

  react-layout-only    6.28 KB gzip   (issue measured ~11.6 KB, with TinyMCE)
  react-hook-only      4.26 KB gzip   (issue measured ~10.4 KB, with TinyMCE)
  client-page-only     7.56 KB gzip   no ai-api, no content-api, no lucene
  analytics-neutral   20.88 KB gzip   no react, no next
  uve-only             3.03 KB gzip

Also asserts, per package: `import` never resolves to Nx's *.cjs.mjs bridge,
`types` is declared first so it is not shadowed by an earlier condition, and a
sideEffects field is present.

Two details worth knowing:

- Module lists come from metafile.outputs[..].inputs filtered to bytesInOutput > 0,
  not metafile.inputs. The latter lists every file esbuild parsed, including
  everything a barrel made it look at and then tree-shook, and would report
  TinyMCE as present in a layout bundle that ships none of it.
- The test target sets cache: false. It reads build artifacts, which Nx does not
  track as inputs, so a cached run replayed a stale pass — both negative checks
  below silently "passed" until this was fixed.

Verified the gate fails on the conditions it is meant to catch: dropping
sideEffects from the built @dotcms/react fails 3 tests and makes both React
probes leak TinyMCE and the block-editor blocks; restoring the *.cjs.mjs bridge
in @dotcms/client fails the ESM-condition and types-ordering assertions.

There is also a guard that the Row/Column stylesheets stay in a layout bundle.
The sideEffects allow-list is what keeps them, and every other assertion here
would still pass if a blanket `false` silently dropped the grid styles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Part of #37571.

Component maps
- examples/nextjs and examples/nextjs-experiments map each content type through
  next/dynamic; examples/astro uses React.lazy, which works without the consumer
  supplying a boundary now that DotCMSLayoutBody wraps each contentlet in one.
- The CustomNoComponent fallback stays eager — it renders when no mapping matches
  and should not wait on a network round-trip.
- examples/astro's content-types barrel re-exported every component with
  `export *`. That alone made all of them statically reachable from the one
  importer, which would have defeated the lazy map no matter how it was written.
  It now exports only the map.
- Same fix in the Astro header barrel, which re-exported AISearchDialog and so
  pulled the dotCMS AI client into the header.

AI search
- The dialog was imported eagerly and rendered unconditionally in all three
  examples. It now loads on first open and stays mounted after that, so its
  behaviour once opened is unchanged.

Verification
- examples/scripts/check-initial-bundle.mjs resolves the chunks a route actually
  downloads — Next.js from the client-reference manifests plus rootMainFiles,
  Astro by requiring a chunk to have no static importer AND be reached through
  import() — and asserts two markers are absent from them but still present
  elsewhere in the build, so a feature that disappeared cannot pass by accident.
- Each example maps UnusedComponentProbe, a content type no page uses, as the
  deterministic fixture.
- The TinyMCE needle is `tinymceScriptSrc`, not the bare string "tinymce":
  @dotcms/uve legitimately ships the editor's URL and toolbar config, and
  matching those reported a failure where none existed.
- core-web/tools/copy-sdk-to-examples.mjs copies the locally built SDKs over the
  installed ones so an example build validates the working tree instead of the
  published packages. It never edits a package.json, so the manifests keep
  floating on `latest` and validate-sdk-package-shapes stays green.

Measured, published 26.9.14-1 vs this branch, gzip:

  DotCMSLayoutBody only        11.29 KB -> 6.13 KB  (-46%), TinyMCE gone
  useEditableDotCMSPage only   10.19 KB -> 4.16 KB  (-59%), TinyMCE gone
  page fetching                10.30 KB -> 7.39 KB  (-28%), via @dotcms/client/page
  analytics root               23.31 KB -> 20.82 KB, React modules 4 -> 0

The two React figures line up with the audit in the issue (~11.6 KB and ~10.4 KB),
which is a useful check that this is measuring the same thing.

Incidental fixes needed to get the examples building at all
- examples/astro did not type-check: 14 errors, reproduced against the published
  packages, so they predate this work. Three views destructured
  useEditableDotCMSPage without defaulting, and three pages used
  `"error" in pageResponse` to narrow — which cannot work, because a successful
  response also carries an optional `error` (the deprecated first GraphQL error).
  Added isPageFetchError, which tests for the thrown DotErrorPage instance.
  astro check is now clean and `npm run build` completes.
- examples/nextjs-experiments had the same missing-default issue in two views.

Docs
- The React SDK README's "Component Mapping" section claimed lazy loading was
  supported while showing an eager snippet in a variable named
  DYNAMIC_COMPONENTS. It now shows both next/dynamic and React.lazy, and warns
  about barrel re-exports.
- Example READMEs and CLAUDE.md files updated to match, so the pattern consumers
  copy is the optimized one.
- examples/astro/README.md referred to a non-existent DotCMSBodyLayout in six
  places.
- libs/sdk/react/CLAUDE.md documented Jest and a jest.config.ts that does not
  exist; the project runs Vitest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… existing apps

Follow-up to #37571, prompted by a customer report: ~135 mapped components
loading on every page in a Next.js app built from our old example.

The SDK changes in this PR do not fix that on their own. The component map lives
in consumer code, so upgrading @dotcms/react cannot make a static map lazy — the
app has to change. What this adds is the guidance and the measurement to do it.

- check-initial-bundle.mjs now reports initial-route raw and gzip totals, and
  takes --report to print them without asserting.
- The React SDK README gains a "Migrating an existing app" section with the
  measured effect, the barrel-export trap, the customRenderers map, and how to
  verify the result.

Measured on examples/nextjs scaled to 135 mapped content types with one
realistic component each:

  static imports   892.7 KB raw / 250.4 KB gzip   135 of 135 in the initial route
  next/dynamic     792.6 KB raw / 238.1 KB gzip     0 of 135 in the initial route

The structural result is the point: a static map puts every mapped component in
the initial bundle, a dynamic one puts none there. The 100 KB is what 135 modest
components cost; real component libraries are heavier, and the saving scales with
them rather than with the count.

Worth recording for whoever reads this later: an earlier version of the
experiment generated 135 near-identical components and measured almost no gzip
difference, because near-identical modules compress to nearly nothing when
bundled together. The components had to be given genuinely distinct markup and
copy before the measurement meant anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five findings from reviewing the preceding commits. Two are defects in that
work, two are structural, one tightens a guard.

The provider useMemo did not memoize
- DotCMSLayoutBody defaulted `components` and `slots` to inline `{}` literals,
  producing new object identities on every render. Those feed the page context's
  useMemo, so it was invalidated every render and the memo added cost without
  ever holding — re-rendering every container and contentlet in the tree.
- Defaults are hoisted to module scope. Measured with a context probe: distinct
  context values across two renders goes from 2 to 1.
- DotCMSLayoutBody.runtime.test.tsx now asserts it, and fails when the defaults
  are put back inline.

Style injection deduplicated on a colliding key
- The injected guard keyed on `css.length`, so a second stylesheet that happened
  to be the same byte length as one already on the page would silently not
  inject. Row (105 bytes) and Column (1586) do not collide today, which is luck
  rather than design. It now compares stylesheet content.

One exports-map patcher instead of two
- The React build had its own package.json patcher duplicating what
  tools/rollup/patch-exports.cjs already does (types first, sideEffects). The
  shared plugin now handles ESM-only packages and takes extraConditions, so
  React declares `react-server` through it and the bespoke copy is deleted.
- Verified behaviour-preserving: the published exports and sideEffects of react,
  client, uve and types are byte-identical before and after.

The shared plugin was in the wrong place
- It lived at libs/sdk/rollup-shared/, which made it a pseudo-project under
  libs/ and tripped @nx/enforce-module-boundaries. It is build tooling, so it
  moves to tools/rollup/ alongside generate-vite-configs.mjs and
  copy-sdk-to-examples.mjs.

Deferred-existence guard was too permissive
- check-initial-bundle.mjs searched all of .next for the "still exists
  somewhere" check, so a marker surviving only in the server bundle would pass
  while having vanished from the browser entirely. It now searches .next/static.

Also adds an assertion that every export condition points at a file that was
actually emitted — the react-server target is a hardcoded path and nothing
checked it resolved.

Not changed, but confirmed while reviewing:
- sideEffects: false is accurate for client, uve, types and analytics. A bare
  side-effect import of each bundles to 0 bytes.
- The virtual:sdk-version TS2307 diagnostic during sdk-vue's dts step is
  pre-existing: it appears identically with the pre-subpath client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

❌ Linked Issue Needs Team Label

This PR is linked to issue #37571, but that issue has no Team : * label. Every linked issue must be owned by a team for tracking and triage.

How to fix this:

Apply a Team : * label to the linked issue (e.g., Team : Scout, Team : Platform, Team : Falcon, Team : Maintenance). Then push a new commit or edit the PR description to re-run this check.


This comment was automatically generated by the issue linking workflow

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fmontes's task in 4m 23s —— View job


PR Review — perf(sdk): stop shipping unused code to browser SDK consumers

  • Gather context (diff, changed files)
  • Review SDK react runtime changes (Contentlet, DotCMSPageProvider, hooks)
  • Review TinyMCE lazy-loading split
  • Review bundle-budgets / publint tooling
  • Review examples & analytics changes

I reviewed the SDK runtime changes, the TinyMCE split, the CSS-injection publishing fix, the bundle/publint gates, and the example conversions. This is careful, well-tested work — the reasoning is captured in the code comments and the guards are verified by breaking them. One genuine interaction is worth a look before merge; everything else is clean.

New Issues

  • 🟡 Medium: core-web/libs/sdk/react/src/lib/next/components/Contentlet/Contentlet.tsx:68 — The new Suspense boundary around CustomComponent interacts with the dev-mode empty-state measurement in a way static imports didn't. useCheckVisibleContent measures height in a useLayoutEffect whose deps are [ref, enabled]. When a content type is mapped lazily (React.lazy/next/dynamic — exactly what this PR promotes), the child is still suspended at mount, so the div measures height 0, haveContent stays false, and style pins minHeight: '4rem'. When the lazy child resolves, React re-renders from the Suspense boundary downward — the Contentlet function body doesn't re-run, and even if it did the effect deps are unchanged, so it never re-measures. Net effect: in edit/dev mode, a lazily-mapped contentlet keeps the empty-state 4rem floor even when it has content. With the old static imports the child rendered synchronously and measured correctly.

    • Assumption: the 4rem minHeight is a floor to make empty contentlets clickable in the editor; taller real content overrides it, so this is cosmetic (extra space under short lazy contentlets), not a break.
    • What to verify: in the UVE editor, map a content type via React.lazy/next/dynamic, render a short instance, and confirm it doesn't get spurious vertical space. If it does, re-measuring after suspense resolves (e.g. a ResizeObserver, or keying the effect off the resolved child) would fix it. This overlaps the "edit mode / inline editing needs a human" items you already flagged.

    Fix this →

Notes (non-blocking, not defects)

  • core-web/libs/sdk/react/rollup.migrated.config.js:32replaceBrokenPostcssPlugin reads options.extractCss but is passed nxOptions; if that key is ever named differently upstream, extract silently falls back to falsy. That happens to be the desired value here (inline injection), so it works — just fragile to an Nx rename. Worth a comment pinning the expectation.
  • injectStyleInline idempotency comparing textContent (not css.length) is correct and the SSR guard is right — good catch on the published-tarball node_modules stripping.
  • The tinymceScriptSrc needle in check-initial-bundle.mjs (rather than bare tinymce) and the mustExistSomewhere counter-check are exactly the right way to avoid a false pass — no issue.

The runtime test (DotCMSLayoutBody.runtime.test.tsx) covers the one-listener / no-reflow-in-prod guarantees, and the bundle-budget probes cover module absence. The lazy/dev-mode measurement interaction above is the one behavior not covered by a test.

Unrelated: the automation comment about the linked issue #37571 missing a Team : * label still needs a human to apply the label — I can't set it.

sdk-components

@github-actions github-actions Bot added Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Sep 16, 2026
Removes @dotcms/client/page, /navigation, /content and /ai, added in c52fe01.

They worked, and the budget probe proved a page-only import dropped ai-api and
the query builders. But measured alone, the saving is 3-5 KB gzip, and only for
a consumer whose browser-side code needs exactly one area:

  createDotCMSClient   8.06 KB gzip
  /page                5.16 KB
  /content             4.64 KB
  /navigation          2.94 KB
  /ai                  3.35 KB

Two problems with shipping that. None of our examples used the subpaths — all
three still build one shared client from the root — so we would have published
four public entrypoints with nothing demonstrating them. And the split only pays
off if you avoid sharing a single client instance across areas, which is the
opposite of the pattern the README documents.

That is public API surface we would carry indefinitely for a benefit nobody in
this repository takes. Better to wait until a consumer asks, with a real usage
shape to design against.

This leaves #37571's "importing page-only client functionality does not retain
AI search and unrelated query-builder code" unmet. Deliberate, and called out in
the PR.

Also reverts the createClientContext extraction that came with it: without the
subpath factories it had a single caller, so it relocated the client's setup
without reducing anything. client.ts is byte-identical to its pre-subpath state.

@dotcms/client keeps the packaging fix from d8fbf3a: `import` resolves to
index.esm.js, `require` to index.cjs.js, `types` first, sideEffects declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes tools/rollup/patch-exports.cjs and the React package.json patcher it
replaced. Both rewrote the generated exports map so `import` resolved to
*.esm.js instead of Nx's *.cjs.mjs interop bridge. Research says that was
solving a problem we do not have.

Nx's shape is deliberate, and its comment says so:

  If CJS format is used, make sure `import` (from Node) points to same instance
  of the package. Otherwise, packages that are required to be singletons (like
  React, RxJS, etc.) will break. Reserve `module` entry for bundlers to
  accommodate tree-shaking.

So `import` is for Node and `module` — written first — is for bundlers. Still
the behaviour on nx master today. The only related report, nrwl/nx#20009, is a
runtime break in the bridge, closed; nobody has filed a tree-shaking complaint
about this shape, which should have been a hint.

Measured, rather than argued:

- A package with Nx's exact generated shape, resolved by esbuild with default
  conditions, lands on index.esm.js and drops unused exports. esbuild honours
  `module` and finds it first.
- examples/nextjs built against the patched packages vs Nx's original shape:
  713.4 KB raw / 212.8 KB gzip against 713.9 KB / 213.0 KB. Two tenths of a
  kilobyte, in exchange for reversing upstream's singleton protection.

The bundle-budget probes are unchanged after removing it (react-layout-only
6294 vs 6282 bytes gzip, noise), which is the same result from the other
direction.

What actually produced the wins, corrected: DotCMSLayoutBody 11.3 -> 6.1 KB and
useEditableDotCMSPage 10.2 -> 4.2 KB came from the TinyMCE split and React's
sideEffects allow-list. @dotcms/react is ESM-only, so Nx already pointed its
`import` at ESM and the patch never applied to it. The exports rewrite
contributed nothing to any headline number.

What replaces ~110 lines of build tooling:

- `sideEffects` moves into the source package.json of client, uve, types and
  react. Verified it propagates to dist untouched, so it needs no plugin.
- React sets generateExportsField: false and hand-writes its exports map, which
  is the only one needing a condition (react-server) Nx cannot generate.

Nothing mutates a package.json at build time any more.

Also drops the two budget assertions that pinned the exports map's shape. The
per-probe check that a bundle contains no CommonJS artifacts stays — it tests
resolution behaviour rather than re-litigating Nx's decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces hand-written packaging assertions with publint, which found two real
defects on its first run. Both pre-existing.

@dotcms/analytics shipped ESM with no module type
  Node: "[MODULE_TYPELESS_PACKAGE_JSON] ... doesn't parse as CommonJS.
  Reparsing as ES module because module syntax was detected. This incurs a
  performance overhead." It worked only through Node's syntax-detection
  fallback; every consumer paid a reparse and older Node fails outright. Adds
  "type": "module". Invisible to a bundler probe or an example build — it is a
  Node resolution concern.

`types` was shadowed in the generated exports maps
  Export conditions match in declaration order, and @nx/rollup writes `module`
  before `types`, so TypeScript can resolve to JavaScript instead of
  declarations. publint reports it as an error. Nx offers no option to change
  the order, so tools/rollup/types-first.cjs moves the key after the fact.

  That plugin reorders keys and nothing else. Every condition still points where
  Nx pointed it — `module` at the ESM build for bundlers, `import` at the CJS
  interop bridge so Node keeps one instance. Deliberate upstream, left alone.
  This is not a return of the exports rewrite removed in 5a7f414: that changed
  targets for no measurable benefit, this fixes an ordering bug an external
  linter flags.

All five packages are now publint-clean.

The gate fails on errors *and* warnings, with an explicit ACCEPTED list. That
distinction is load-bearing: the analytics defect was reported as a warning, so
an errors-only gate would have let through the exact bug that justified adopting
the tool. Verified by removing "type": "module" from the built package and
watching the suite fail with the three offending paths named.

Two findings are accepted for now, each with a reason in the code:
INVALID_REPOSITORY_VALUE (repo-wide convention) and EXPORTS_TYPES_INVALID_FORMAT
(needs .d.mts output and per-condition `types`; a wider build change, and types
resolve correctly today).

sdk-bundle-budgets keeps only what publint cannot do: proving a named module is
absent from a bundle. Its export-target-exists assertion is dropped as redundant.

Worth recording, since it is the reason the probes survive: a size budget is not
a substitute for the absence assertion. Removing `sideEffects` from the built
@dotcms/react and measuring with size-limit moved DotCMSLayoutBody 4.3 KB ->
4.3 KB and useEditableDotCMSPage 4.0 -> 4.6 KB under rolldown, and 4.25 -> 4.25
and 3.96 -> 4.55 under esbuild. Every configuration passed its budget. The named
assertion fails loudly and says which modules came back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives consumers a way to see whether their content-type components actually load
on demand, which prompted this whole effort — a customer followed our old
example and ended up with 135 components on every page.

There is no built-in way to check. `next build` with Turbopack prints no First
Load JS column, `next build --experimental-analyze` emits a route diagnostic
that never names components, and `@next/bundle-analyzer` is a webpack plugin
that produces nothing at all on a Turbopack build. All three were tried.

So `npm run analyze` resolves the chunks a route actually downloads — from the
build manifest plus the client-reference manifests — and reports the totals and
how many more load on demand. Passing a string reports whether that code is in
the initial download or deferred.

The script insists on a string from the component's output rather than its name,
and the README explains why: the map keys live in the initial chunk by design.
`Banner: dynamic(() => import("./Banner"))` compiles to a loader that decides
when to fetch the component, so grepping for "Banner" finds those few bytes and
says nothing about where the component went. The first version of this script
reported Banner as eagerly loaded for exactly that reason.

Matches examples/scripts/check-initial-bundle.mjs on the same build (14 chunks,
713.4 KB raw, 212.8 KB gzip). That script stays the CI guard with its
pass/fail assertions; this one is for a consumer looking at their own app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both bundle scripts unioned every route's initial chunks and labelled the total
"initial route JavaScript". Routes share most of the app shell, so the union is
larger than anything a visitor downloads — on examples/nextjs it reported
212.8 KB gzip where the heaviest real route is 178.6 KB and the main content
page is 172.7 KB. A 23% over-report here, and worse on an app with more distinct
routes.

npm run analyze now prints a row per route. The CI script keeps the union, which
is what its assertions need — a module must be absent from every route — but
says so instead of calling it a route.

This corrects the before/after figures quoted in the PR. Measured per route on
the main content page, same 14 content types both ways:

  static imports   619.7 KB raw / 181.3 KB gzip, 6 on-demand chunks
  next/dynamic     594.7 KB raw / 172.7 KB gzip, 20 on-demand chunks

8.6 KB gzip, not the 28.9 KB the union implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing referenced it — no npm script, no CI job, no documentation, only its own
usage comment. It was written to verify example builds against local SDK changes
during development and then committed, which is not a reason to carry it.

The workflow it automated is a copy loop over dist/libs/sdk into an example's
node_modules. Anyone who needs it can write it, and CI never builds examples at
all, so nothing in the pipeline loses a step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[SDK] Improve tree shaking and runtime performance across React, client, UVE, types, and analytics

1 participant