arkynChangelogGuides
docs / changelogs / breaking-changes

Breaking Changes

This document gathers only the Arkyn versions that require some code change from the library's consumers. Versions with no direct impact on your code do not appear here, see Latest for the current version, or the v3.x.x changelog and v3.0.1-beta changelog for the full version-by-version history.
If you're upgrading straight from v2.2.3 to v3.0.2, see the consolidated migration guide instead, it covers every breaking change across that whole jump, including ones made before this changelog started tracking versions individually at v3.0.1-beta.139.

v3.0.12

What changed

One breaking change, plus four lower-impact behavior changes that can break consumers relying on the old behavior:
  1. BadResponse cause is no longer JSON-stringified (breaking). BadResponse and its subclasses (ServerError, BadGateway, BadRequest, Conflict, Forbidden, NotFound, NotImplemented, Unauthorized, exported from @arkyn/server) no longer serialize cause with JSON.stringify() before storing it. Previously, cause was always converted to a JSON string (or dropped to undefined if falsy); now it's kept exactly as passed, the same way UnprocessableEntity already worked.
  2. Tooltip escapes tags outside its allowlist (@arkyn/components). text is now sanitized: only b, strong, i, em, u, br, span, and small render as HTML, with every attribute removed. script, style, iframe, object, embed, and noscript are removed with their content, and any other tag (<div>, <a>, <p>, <code>, <img>, <ul>, ...) is HTML-escaped and shown as literal text.
  3. LogService.getConfig() no longer returns userToken (@arkyn/server). It returns { trafficSourceId, serviceToken, apiUrl } | undefined.
  4. LogService.setConfig() throws without a token (@arkyn/server). If neither serviceToken nor the deprecated userToken alias resolves to a non-empty string, it throws Error("LogService.setConfig() requires a serviceToken (or the deprecated userToken alias).").
  5. ApiService requests time out after 10s by default (@arkyn/server). Requests used to have no timeout at all. A request whose response headers don't arrive within timeoutMs (default 10000) resolves to { success: false, status: 504, message: "Request timed out after 10000ms", response: null, cause: <Error> }.

How to migrate

BadResponse cause: if your code does JSON.parse(error.cause) to read the object back out, remove the JSON.parse() call, error.cause is now the object itself:
Before

ts

const err = new BadRequest("Invalid payload", { field: "email" });
const details = JSON.parse(err.cause); // cause was '{"field":"email"}'
console.log(details.field); // "email"
After

ts

const err = new BadRequest("Invalid payload", { field: "email" });
console.log(err.cause.field); // "email" — no JSON.parse needed
If you rely on cause being undefined for a falsy value, note that only "no cause passed at all" is undefined now. Passing 0, "", false, or null as cause is preserved as that exact value (previously they were all silently converted to undefined, a data-loss bug this change also fixes), so cause === undefined is no longer a safe check for "was a cause passed."
Tooltip: stick to the allowlisted tags and don't rely on attributes. Replace block or link markup with plain text or allowed inline tags:
Before

tsx

<Tooltip text='<div class="title">Saved</div><a href="/help">Help</a>'>...</Tooltip>
After

tsx

<Tooltip text="<strong>Saved</strong><br />See the help page">...</Tooltip>
getConfig().userToken: read serviceToken instead, and rename the key you pass to setConfig while you're at it (userToken still works there, but logs a dev warning):
Before

ts

LogService.setConfig({ trafficSourceId, userToken: env.ARKYN_LOG_TOKEN });
const token = LogService.getConfig()?.userToken;
After

ts

LogService.setConfig({ trafficSourceId, serviceToken: env.ARKYN_LOG_TOKEN });
const token = LogService.getConfig()?.serviceToken;
setConfig throwing: make sure the token is always set before calling setConfig(), for example by validating the environment variable at boot, or skip the call entirely when logging isn't configured.
ApiService timeout: for endpoints that can take longer than 10s to start responding, pass a larger timeoutMs, per instance or per request (the per-request value wins):

ts

const api = new ApiService({ baseUrl, timeoutMs: 30000 });
await api.post("/reports/generate", { body, timeoutMs: 60000 });

Impact

Low to moderate. Anyone calling JSON.parse(error.cause), or exact-string-matching a stringified cause in tests or client code, needs to update that code, cause is a plain value/object now, not a JSON string. Consumers who only read cause for local debugging (the common case) see no required change, just a nicer shape (a real object instead of a string to re-parse).
The other four changes are low impact: they only affect apps that put non-allowlisted HTML or attributes in Tooltip's text (it now shows up as visible markup), read getConfig().userToken, call setConfig() with a missing or empty token, or call endpoints slower than 10s through ApiService (they now resolve with status 504 instead of waiting).

Notes

BadResponse also now extends the native Error class (real .stack, instanceof Error works, .name reflects the subclass), which is what made this consistent across all subclasses. The ApiService timeout only covers the time until response headers arrive, reading the body isn't covered. See the v3.x.x changelog for the full v3.0.12 release notes.

v3.0.10

What changed

BadResponse and its subclasses (ServerError, BadGateway, BadRequest, Conflict, Forbidden, NotFound, NotImplemented, Unauthorized, exported from @arkyn/server) no longer include a cause field in the JSON response body when NODE_ENV === "production". Previously cause was always included, in every environment, which could expose stack traces, SQL fragments, or other internal error detail to clients in production. UnprocessableEntity is the one exception, it still includes cause in production, its cause is now redacted, structured field/validation data intended for the client (see the v3.x.x changelog), not internal diagnostic detail.

How to migrate

If your frontend or API client reads error.cause from a production error response body (any status other than 422), it will now be undefined there. It's still present in development, and in any NODE_ENV value other than "production". message and name are unaffected in every environment.
Before

ts

// In production, this response body no longer has a "cause" key
// (except for UnprocessableEntity's 422 responses)
throw new ServerError("Database operation failed", { table: "users" });
After

ts

// If you depend on cause reaching the client, either move that data
// into "message", or keep it in a 422 via UnprocessableEntity's
// redacted "data"/"fields", which are still sent in production.
throw new UnprocessableEntity({
message: "Database operation failed",
data: { table: "users" },
});

Impact

Low for most consumers, cause was intended for local debugging, not client-facing logic. Anyone parsing cause specifically in production error-handling code needs to stop, or move that dependency to a 422 (UnprocessableEntity) response, where it's preserved (redacted).

Notes

See the v3.x.x changelog for the full v3.0.10 release notes, including a related fix, UnprocessableEntity's own cause (fields/data) is now redacted with parseSensitiveData regardless of environment, independently of this production-hiding change.

v3.0.9

What changed

LogService.getConfig() (exported from @arkyn/server) now types apiUrl as string | null instead of string. This is a consequence of removing a hardcoded, plain-http:// fallback endpoint: when no valid logBaseApiUrl (an https:// URL, or http://localhost/http://127.0.0.1 for local dev) has been configured, apiUrl is null and no log requests are sent, rather than silently pointing at Arkyn's old default log server.

How to migrate

If your code reads LogService.getConfig()?.apiUrl and assumed it was always a non-null string, add a null check:
Before

ts

const config = LogService.getConfig();
console.log(config.apiUrl.toUpperCase());
After

ts

const config = LogService.getConfig();
if (config?.apiUrl) {
console.log(config.apiUrl.toUpperCase());
}
If you were relying on logs being sent without ever calling LogService.setConfig({ logBaseApiUrl: ... }), configure an explicit https:// endpoint, the old hardcoded fallback no longer exists:

ts

LogService.setConfig({
trafficSourceId: "your-traffic-source-id",
userToken: "your-user-token",
logBaseApiUrl: "https://your-log-server.example.com",
});

Impact

Low for typical usage, most consumers only call LogService.setConfig() and never read getConfig().apiUrl directly. TypeScript will flag any direct property access on apiUrl that isn't null-checked. Anyone who never configured logBaseApiUrl and depended (knowingly or not) on logs still reaching Arkyn's old default server needs to set an explicit, secure endpoint now, nothing is sent otherwise.

Notes

This change also fixes a real security gap: request/response headers and bodies sent to the log endpoint, and to ApiService's debug output, are now redacted with parseSensitiveData before being sent or logged. See the v3.x.x changelog for the full v3.0.9 release notes, including 17 other non-breaking security, bug, and accessibility fixes in the same release.

v3.0.5

What changed

RichText's video insertion modal now requires the URL to use https://, in addition to the ?v= parameter check it already performed. A YouTube URL passed as http://... is no longer accepted.

How to migrate

No code change is required. If you were relying on inserting video URLs written as http://..., use the https:// form instead, YouTube itself serves both, so this is typically just a matter of the URL string used.

Impact

Very low. Only affects RichText's video insertion modal, and only when the pasted URL explicitly uses the http:// scheme. Video embeds already saved from previous versions are unaffected, this only gates new insertions made through the modal.

Notes

The same https:// check now also gates the new link insertion feature introduced in v3.0.5. See the v3.x.x changelog for the full v3.0.5 release notes.

v3.0.4

What changed

PhoneInput's controlled-value masking (introduced in v3.0.3) no longer misplaces the country dial-code digits when a value string is in international format (starting with +), for example when onChange's value is round-tripped straight back as value, a common controlled-input pattern in React. The dial code is now stripped before masking; values without a leading + are unaffected.

How to migrate

No code change is required. If you rely on the round-trip pattern below, the display now shows the correct, unshifted value:

tsx

const [value, setValue] = useState("");
<PhoneInput name="phone" value={value} onChange={setValue} />
Before (v3.0.3), typing "34999998888" fed onChange's "5534999998888" back into value, and the dial-code digits were read as part of the local number, shifting the display. After (v3.0.4), the same code shows "(34) 99999-8888" correctly.

Impact

Very low. Only affects controlled PhoneInput usage where value is a string starting with + (typically the onChange round-trip pattern). Consumers passing local digits without a leading +, the pattern already documented as of v3.0.3, see no change. This is best understood as a bug fix for a defect introduced by v3.0.3's masking change, not a deliberate behavior change.

Notes

See the v3.x.x changelog for the full v3.0.4 release notes, and the v3.0.3 entry above for the original controlled-value masking change this refines.

v3.0.3

What changed

PhoneInput's controlled value is now formatted with the selected country's phone mask before being displayed. Previously, a controlled value rendered exactly as passed, while uncontrolled typing already applied the mask, so the two modes were inconsistent.

How to migrate

No code change is required to keep compiling or running, the prop signature is unchanged. Review any place that:
  • Displays the raw PhoneInput value elsewhere and expected it unmasked.
  • Has snapshot/unit tests asserting the field's displayed text.
Before

tsx

<PhoneInput name="phone" value="34999998888" />
// Rendered: "34999998888"
After

tsx

<PhoneInput name="phone" value="34999998888" />
// Renders: "(34) 99999-8888"
If you had your own masking logic before passing value to compensate for the old behavior, it's now redundant, non-digits are stripped before re-masking, so an already-formatted string still works the same way, and can be removed.

Impact

Affects any project using PhoneInput in controlled mode (value prop). It doesn't break compilation or throw at runtime, it's a rendered-output change only, but it can break snapshot tests or surprise consumers relying on the old, unmasked display. There's no prop to opt back into the previous, unmasked behavior.

Notes

This also fixes a real inconsistency: the same component previously showed unmasked text when controlled and masked text when typed into directly. See the v3.x.x changelog for the full v3.0.3 release notes.

v3.0.1-beta.174

What changed

The schema prop, used by every Alert component (AlertContainer, AlertContent, AlertDescription, AlertIcon, and AlertTitle), was renamed to scheme. This change unifies the naming already adopted by Button, Badge, and IconButton across the library.

How to migrate

Rename schema to scheme in every use of Alert.
Before

tsx

<AlertContainer schema="danger">...</AlertContainer>
After

tsx

<AlertContainer scheme="danger">...</AlertContainer>

Impact

Affects any project that uses Alert with the schema prop. The old name is no longer recognized and is silently ignored, Alert still renders, but without applying the expected color scheme.

Notes

The scheme pattern was already used by the library's other components; this change simply aligns Alert with the existing convention.

v3.0.1-beta.170

What changed

UI dependencies used internally by @arkyn/components, framer-motion, mapbox-gl, slate, slate-history, slate-react, @react-google-maps/api, react-hot-toast, react-scroll, html-react-parser, and @react-input/mask, are no longer installed automatically and are now peerDependencies. In addition, the minimum required version of react and react-dom was corrected.

How to migrate

Explicitly install, in your project, the dependencies related to the components you use:

bash

bun add framer-motion mapbox-gl slate slate-history slate-react \
@react-google-maps/api react-hot-toast react-scroll html-react-parser \
@react-input/mask
You don't need to install all of them, only the ones used by the components present in your project (for example, framer-motion for Drawer/Modal, mapbox-gl for MapView, slate* for RichText).

Impact

Affects any project that relied on the transitive installation of these libraries. Without installing them manually, the components that depend on them stop working correctly.

Notes

The minimum react/react-dom requirement was corrected from >=19.2.6 to >=18.0.0, restoring compatibility with React 18 projects that had been incorrectly blocked.

v3.0.1-beta.148

What changed

The defaultValue prop on FullCalendar, used to set the initially focused date, was renamed to defaultViewValue.

How to migrate

Before

tsx

<FullCalendar defaultValue={new Date(2026, 5, 1)} />
After

tsx

<FullCalendar defaultViewValue={new Date(2026, 5, 1)} />

Impact

Affects only those who already used FullCalendar with the defaultValue prop to control the initially displayed date. The old prop is silently ignored in this version, without the rename, the calendar always opens on the current date.

Notes

This change standardizes the naming between FullCalendar and Calendar: value controls the selection, viewValue controls the displayed period, and defaultViewValue sets the initial period.

v3.0.1-beta.147

What changed

Three changes to FullCalendar's API:
  1. The date property on event objects (FullCalendarEvent) was renamed to initialDate.
  2. The defaultView prop, which set the initial view ("day" | "week" | "month"), was removed, the component now always initializes in month view.
  3. The onChange prop was removed.

How to migrate

Rename date to initialDate on every event:
Before

tsx

events={[{ title: "Meeting", date: new Date(2026, 5, 23, 10, 0) }]}
After

tsx

events={[{ title: "Meeting", initialDate: new Date(2026, 5, 23, 10, 0) }]}
If you used defaultView="day" or defaultView="week", remove the prop, there is no direct replacement; the calendar always opens in month view.
If you used onChange to react to date changes, replace it with onClickDate (user click on a cell) or onChangeView (navigation between periods), depending on the intended behavior:
Before

tsx

<FullCalendar onChange={(date) => setSelectedDate(date)} />
After

tsx

<FullCalendar onClickDate={(date) => setSelectedDate(date)} />

Impact

Affects only those who already integrated FullCalendar since its introduction in the previous version (v3.0.1-beta.146). Anyone who hadn't yet used the component is not impacted.

Notes

These changes accompany the introduction of blockedTimestamps and onClickDate, which expand control over calendar interactions. The same version also made "vertical" the default orientation for ImageUpload, MultiSelect, RadioGroup, RichText, and Select, that part does not require a code change and is therefore not listed as a breaking change here; see changelog for details.
On this page
    arkyn