`src/polyfills.ts` is five side-effect-only imports. The `sideEffects` globs only matched
`./dist/**`, and rolldown matches that field against SOURCE paths while bundling, so every
`src/polyfills/*.ts` was declared pure and dropped. Every published version through 1.69.2
shipped a 195-byte barrel installing nothing but streaming fetch, so an app following the
documented setup died on its first runtime call with `Property 'ReadableStream' doesn't exist`.
`dist/index.mjs` and `dist/headless.mjs` lost the same imports, so the package's advertised
auto-install on first import did not happen either.
Add matching `./src/**` globs. The barrel goes 195B to 362B with all five imports, and
`headless.mjs` now leads with `import "./polyfills.mjs"`.
`src/__tests__/polyfills.test.ts` stayed green throughout this, because it imports the source,
which is never bundled and so is never tree-shaken. Add `scripts/verify-polyfill-barrel.mjs`,
which checks `dist/` instead. It clears the nine globals first (Node ships them natively and
Hermes does not, so asserting they are merely "defined" would pass on an empty barrel), then
executes the CJS barrel in a child realm and checks the ESM barrel structurally. ESM cannot be
executed here: the encoding polyfill takes a named import from CommonJS `text-encoding`, which
Metro rewrites to a require() but bare Node ESM rejects.
The check runs from `build`, so a dead barrel fails the build rather than reaching npm.
Also document the `ReadableStream doesn't exist` symptom in troubleshooting, where only the
inverse case (a polyfill *conflict*) was covered before.
Verified: reverting the sideEffects change and rebuilding turns the check red in both formats,
5 of 5 groups; restoring it turns it green. A behavioural check on the packed tarball installs
all nine globals. 289 vitest + 26 script tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both scripts decided "am I the entrypoint?" by comparing `import.meta.url` to a
`file://`-concatenated `process.argv[1]`. `import.meta.url` is percent-encoded
and symlink-resolved; raw argv[1] is neither. So the comparison was false for
any checkout path containing a space, for any invocation through a symlink
(macOS /tmp is one), and on Windows — and a false guard skipped the whole CLI
block. Reproduced before fixing: the #4893 purity gate and the bundle-size
measurement both exited 0 having printed nothing and asserted nothing, which is
worse than a gate with holes because it manufactures confidence. The guard was
added by this PR so the modules could export internals to their new negative
tests; making the gates testable introduced a way for them not to run.
Both now compare real filesystem paths through an exported `isEntrypoint`:
`fileURLToPath` defeats the encoding and Windows forms, `fs.realpathSync` on
both sides defeats symlinks, and a `path.resolve` fallback keeps a nonexistent
argv[1] from throwing.
Each `node --test` suite gains five entry-guard tests, including an end-to-end
spawn of the real script through a symlinked package-root alias whose name
contains a space — the only case that catches the call site regressing back to
a string comparison (verified: it fails against the old expression). The unit
cases assert the naive comparison really would have failed, so none of them can
pass vacuously. Both negative gates were re-proven to still bite: a doctored
dist entry pulling streamdown fails the purity gate, and a stubbed dist entry
trips the measurement's plausibility floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The esbuild `external` list omitted `react-dom`, unlike react-core's
measure-copilotchat.mjs, so a stray web-oriented edge could be absorbed
into the figure the PR's bundle claim rests on.
Checked empirically before changing anything: `react-dom` is NOT reachable
from @copilotkit/react-native/headless today. A metafile run shows 0 of the
653 input modules are react-dom, and no module references it even
pre-resolution. The reported figure is therefore UNCHANGED — 94941 B gzip
(92.7 kB) before and after, byte for byte. The headline "92.8 kB -> 92.7 kB,
flat" claim is unaffected and stays comparable with previously reported
numbers.
The guard is still worth having. Simulating a stray edge measures the
inflation it prevents: +56.3 kB gzip via react-dom/client, +57.3 kB via
react-dom/server (not the ~130 kB estimated in review — that is closer to
the raw magnitude; react-dom-client.production.js is 536 kB raw). The
subtler case is a bare `react-dom` edge at +1.4 kB, small enough to read as
noise while still being a real regression.
No subpath entries: esbuild prefix-matches package paths, so `react-dom`
already covers react-dom/client and react-dom/server (verified on the
pinned 0.27.3; esbuild CHANGELOG 0.5.14 and 0.14.13). Listing them would
imply they were required.
Hoisted the list to an exported HEADLESS_EXTERNAL with per-entry rationale,
mirroring the sibling's DEFAULT_EXTERNAL, and made `external` an overridable
option so the new test can A/B it rather than assert on a literal. Both new
tests fail if react-dom is removed from the list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
measure-headless.mjs prints the number the PR's bundle claim rests on, and it
had three ways to report a broken run as a good one. All three reproduced:
1. No zero-output guard (the react-core sibling has one). A run whose bundle
collapses to nothing measures ~20-35 B of gzip envelope, prints "0.0 kB"
and exits 0 — reported into the CI job summary as a spectacular win. Note a
zero-ONLY guard would not have caught the reproduction (35 B, not 0), so
this adds a plausibility FLOOR of 8 kB alongside the zero check: ~11x below
the real 92.7 kB, so legitimate size work can never trip it.
2. `logLevel: "silent"` discarded `result.warnings` and there was no
try/catch, so esbuild resolution problems escaped as an unhandled rejection
printing esbuild's internal frames and `errors: [Getter/Setter]` instead of
the messages. Silent is kept (as in the sibling) so stdout stays the single
figure line CI quotes; warnings are now formatted to stderr and errors are
re-thrown with esbuild's own formatted diagnostics.
3. An unbuilt dist died on a raw "Could not resolve" stack. A preflight on
dist/headless.mjs now names `npx nx run @copilotkit/react-native:build`,
and the catch adds the same hint when the entry specifier is what failed.
The measurement itself is untouched — same esbuild options, same synthetic
entry, same six symbols, same external list — and still reports 92.7 kB, so
comparability across PRs is preserved. A moved figure would have meant the
measurement changed rather than its guards.
Also adds the test hook RN lacked, mirroring react-core exactly:
scripts/__tests__/measure-headless.test.mjs under `node --test`, wired as
`test:scripts` and chained into `test`. Coverage targets the failure modes,
not the happy path. Both packages' vitest `include` globs are scoped to
`src/**`, so the .mjs test cannot collide with the jsdom setup — the reason
the sibling runs under node --test in the first place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>