Skip to content

fix: close the contract gaps the first consuming app worked around - #8

Open
neotrow wants to merge 2 commits into
mainfrom
feat/close-app-contract-gaps
Open

neotrow wants to merge 2 commits into
mainfrom
feat/close-app-contract-gaps

Conversation

@neotrow

@neotrow neotrow commented Sep 23, 2026 •

Copy link
Copy Markdown

Auditing csag-blueprint-web#309, the PR that moves the blueprint app onto these packages, produced a list of places where the app had to keep code, coerce a type or re-implement a helper because of something in here. This is that list.

Everything is additive or a fix. No existing call site has to change, which is why the changeset is a patch — happy to make it a minor if the team reads "new exports" as deserving one.

The three that are actually bugs

Lockstep is not enforced by the published manifests. blueprint-form-kit and blueprint-api-kit declared blueprint-core as workspace:^, which publishes as ^0.1.0:

$ npm view @collana-solutions/blueprint-form-kit@0.1.0 dependencies
{ '@collana-solutions/blueprint-core': '^0.1.0' }

The app pins core exactly and excludes the scope from its 14-day age floor. The first time a 0.1.1 exists, its monthly lockfile refresh re-resolves, ^0.1.0 accepts the newer core, and the tree ends up with core 0.1.1 beneath form-kit and api-kit while the direct dependency stays on 0.1.0 — two copies, in a PR titled "chore: refresh lockfiles". Core holds only pure functions today so the blast radius is small, but the "all six at one version" promise in both READMEs would be quietly false. workspace:* publishes the exact version, which is what the fixed changeset group already implies.

resolveClientTranslations trusted localStorage. It returned JSON.parse(stored).data with no check. typeof null === 'object', so a stored {"data":null} resolved to null and the consumer threw at its first property access — during render, before TranslationGuard could switch to recovery and reload. That is the crash behind two Copilot findings on the app PR; the app patched its own render site and correctly noted the root cause lives here. Validation now lives in an exported readStoredTranslations, and an entry that fails it is treated as a cache miss, so the caller gets its fallback and the guard can do its job. localStorage outlives deployments, so an entry written against an older schema is a normal occurrence, not a corner case.

Development diagnostics stopped firing once this shipped as a package. isDevMode read import.meta.env.DEV. Vite injects that through its import-analysis plugin, which runs over app source; a package resolved from node_modules is instead pre-bundled by the dependency optimizer, which does not define it. So every development diagnostic in the form kit — notably the orphan-field warning in useSchemaForm — went silent the moment this code stopped being vendored app source and became the published package it now is.

The obvious fix is wrong, and the first commit here had it. process.env.NODE_ENV is inlined by rolldown's browser platform at our build time, so the built artifact came out carrying a frozen "development" — every consumer would have been told it was a dev build, forever. Reading it at runtime does not work either: Vite substitutes the text rather than creating a global process, so a browser lookup finds nothing. Making the literal survive our build would mean changing platform for all six packages.

A published package cannot determine this for itself, so it no longer pretends to. setDevMode lets the consumer declare it, from its own source where the bundler does substitute:

setDevMode(import.meta.env.DEV)

The old heuristic remains the default, so consuming these packages as source is unchanged, and an app that never calls it gets quiet diagnostics — the right production default.

Contract shapes the app had to work around

Label leaves rejected null. The backend marks the unsaved-changes copy nullable, so the generated client produces string | null, so __root.tsx carries four ?? undefined coercions that look like runtime guards and are not — mergeLabels has always discarded non-strings. PartialFormKitLabels now accepts string | null | undefined at every leaf.

Zod field labels required a Record. TypeScript gives interfaces no implicit index signature, so the generated TranslationValuesFieldsValues could not be passed and the app spread it into a fresh object purely to change its declared type. setZodValidationMessages is generic over its fields argument via a new ZodFieldNamesOf<T>; the redundant double cast inside resolveFieldName is gone too.

English copy had no override. setErrorNotificationLabels covers the error toast's five strings. A module-level setter rather than a prop because that toast is raised from axios interceptors and query-cache handlers, which do not sit in the React tree holding the translations — the same bridge the zod kit already uses. TranslationGuard takes a labels prop; its doc comment spells out that these cannot come from the translations it exists to recover from.

CSS custom property names were fixed. buildTenantCssVars emitted --accent and friends, the names one app's styles.css happens to declare. It takes an optional varNames; defaults unchanged.

Things the app was holding that belong here

The field components are exported. TextInputField, SelectField and the rest are named exports now. The registry itself stays fixed, so this does not yet let a consumer add an eleventh field.* member — but it removes the reason fields/index.ts existed as a dead barrel, and that barrel was also missing two of the ten it claimed to re-export. Both READMEs now state the rule the app discovered the hard way: an app-coupled field is a plain component over useFieldContext, rendered as the body of form.AppField.

formatMessage. The only placeholder helper here used {{key}}. The backend stores validation copy with {FieldName}, {Min}, {Max}, and this repo's own zod error map fills single braces — so interpolate matched nothing the stack emits, the app had zero usages of it and twelve of its own formatMessage. Added with the right syntax; interpolate is deprecated and kept for one minor.

Not in this PR

  • The registry factory. Making fieldComponents extensible is what would finally let the app's dropzone FileInputField come home. It needs a design pass, and the plain FileInputField this package still registers as field.FileInput should be decided at the same time — it is stale relative to the app's and reachable by any other consumer.
  • The table view. The backend engine is open source and the only renderer is 1515 closed lines. Biggest remaining asymmetry, wants its own package.
  • Branch protection and preview builds. main has no protection and pkg.pr.new is still parked, so there is no way to test a change here against the app before a version is permanently burned. Worth closing before the next wave.

Verification

prettier --check, eslint, tsc --noEmit across all six, pnpm build, pnpm test (56/56, up from 45), publint --strict and attw --profile esm-only all clean.

New tests: cache validation including the data: null regression, both placeholder syntaxes including that they do not collide, and the label defaults.

🤖 Generated with Claude Code

neotrow and others added 2 commits September 22, 2026 21:43
Auditing csag-blueprint-web's migration onto 0.1.0 surfaced a set of places
where the app had to hold code, coerce a type or re-implement a helper because
of something this repo got slightly wrong. All of it is additive or a fix; no
existing call site has to change.

Internal dependencies published as ^0.1.0 rather than the exact version. A
consumer pinning blueprint-core exactly would, on the next release, resolve a
second copy of core beneath form-kit and api-kit while its own direct dependency
stayed behind — two versions of a package the fixed changeset group promises are
always in lockstep. workspace:* publishes the exact version.

resolveClientTranslations trusted localStorage. A corrupt or older-schema entry
holding {"data":null} resolved to null, and the consumer threw at its first
property access before TranslationGuard could reload — which is the crash two
review findings on the app PR chased to the render site. Validation now lives in
an exported readStoredTranslations, and a rejected entry is a cache miss.

isDevMode read import.meta.env. Vite injects that into app source but not into a
pre-bundled dependency, so every development diagnostic in the form kit went
silent the moment the kit stopped being vendored code and became the published
package it now is. It prefers process.env.NODE_ENV, which the dependency
optimizer does define and which rollup, webpack, esbuild and Jest set too.

The remaining gaps were contract shapes the app had to work around: label leaves
rejected null although a nullable backend column produces it, and the zod field
labels required a Record where a generated interface has no index signature, so
both forced a coercion at the call site that looked like a runtime guard and was
not. English copy in the error toast and the translation guard had no override.
CSS custom property names in the theming helper had to match one app's
stylesheet. The field components were unexported, and the barrel listing them
was missing two of the ten. formatMessage arrives because the only placeholder
helper here used {{key}} while the whole rest of the stack speaks {Key}, so it
matched nothing the backend emits and every consumer wrote its own; interpolate
is deprecated for one minor.

Tests cover the cache validation, both placeholder syntaxes and the label
defaults. Verified: prettier, eslint, tsc, build, 56/56 vitest, publint and attw
all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading process.env.NODE_ENV was the wrong fix for the silent diagnostics.
These packages build with rolldown's browser platform, which inlines that
expression at build time — the published artifact came out carrying a frozen
`"development"`, so the kit would have reported a development build to every
consumer forever.

Nor can it be fixed by reading the value at runtime: Vite substitutes the text,
it does not create a global `process`, so a browser lookup finds nothing. The
literal would have to survive our build to be replaced by the consumer's, which
means changing the build platform for all six packages.

A published package cannot work this out for itself, so it should not pretend
to. `setDevMode` lets the consumer say, from its own source where the bundler
does substitute:

    setDevMode(import.meta.env.DEV)

The import.meta.env heuristic stays as the default, so consuming these packages
as source — and this repo's own tests — behave exactly as before, and an app
that never calls it gets quiet diagnostics, which is the right production
default.

This also drops the `declare global var process` block, which would have
collided with @types/node in any consumer that has it.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants