From 952f145d34c94ec5b45b1c51a11855fa3ba19e7a Mon Sep 17 00:00:00 2001 From: Josemar Luedke Date: Fri, 25 Sep 2026 10:59:58 -0700 Subject: [PATCH 1/4] refactor(forms): replace get() from @ember/object with getPath RFC 1234 deprecates the EmberObject object model. The standalone get() function is not in its table, but it is the likeliest next target. - getConfigOption: keys are flat, so plain property access is enough. - Field#fieldValue: needs dotted paths, so it now uses a small getPath helper that reads own properties only and refuses __proto__, constructor and prototype segments, since field names can come from untrusted schemas. The unsafe-key list moves to -private/unsafe-keys so nested-data and getPath share it. Co-Authored-By: Claude Opus 5.5 --- packages/frontile/src/-private/get-config.ts | 3 +- packages/frontile/src/-private/unsafe-keys.ts | 30 ++++++++++ .../frontile/src/components/forms/field.gts | 6 +- packages/frontile/src/utils/get-path.ts | 28 +++++++++ packages/frontile/src/utils/nested-data.ts | 33 +--------- .../tests/unit/forms/utils/get-path-test.ts | 60 +++++++++++++++++++ 6 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 packages/frontile/src/-private/unsafe-keys.ts create mode 100644 packages/frontile/src/utils/get-path.ts create mode 100644 test-app/tests/unit/forms/utils/get-path-test.ts diff --git a/packages/frontile/src/-private/get-config.ts b/packages/frontile/src/-private/get-config.ts index 1afd488e6..6382161b8 100644 --- a/packages/frontile/src/-private/get-config.ts +++ b/packages/frontile/src/-private/get-config.ts @@ -1,4 +1,3 @@ -import { get } from '@ember/object'; import type { DefaultConfig } from './types'; export function getConfigOption( @@ -6,7 +5,7 @@ export function getConfigOption( key: T, defaultValue: NonNullable ): NonNullable { - const value = get(config, key); + const value = config[key]; if (value === undefined) { return defaultValue; diff --git a/packages/frontile/src/-private/unsafe-keys.ts b/packages/frontile/src/-private/unsafe-keys.ts new file mode 100644 index 000000000..f26c9c164 --- /dev/null +++ b/packages/frontile/src/-private/unsafe-keys.ts @@ -0,0 +1,30 @@ +/** + * Path segments that are refused as object keys. + * + * Field names come from the `name` attribute of the form's controls, which an + * app may render from a server-supplied schema, a CMS, or URL state — so they + * are untrusted input. + * + * `__proto__` is the real vector: `isPlainObject(Object.prototype)` is `true`, + * so when `unflattenData`'s walk reached a `__proto__` segment it accepted + * `Object.prototype` as an already-existing nested object instead of creating a + * fresh one, and the final assignment wrote onto every object in the + * application. + * + * `constructor` and `prototype` do not reach `Object.prototype` through that + * walk as it is written — `current['constructor']` is a function, which + * `isPlainObject` rejects, so the walk shadows it with a fresh own key. They + * are refused anyway, as defense in depth: it keeps form data from shadowing + * those names, and it means a later change to the walk (or an intermediate that + * is a plain object with a `constructor` of its own) cannot quietly turn them + * into live vectors. + */ +const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; + +/** + * Checks a single path segment. Callers refuse the whole entry rather than + * substituting a safe key, so no partially-built path is left behind. + */ +export function isUnsafeKey(key: string): boolean { + return UNSAFE_KEYS.includes(key); +} diff --git a/packages/frontile/src/components/forms/field.gts b/packages/frontile/src/components/forms/field.gts index 3992c0592..a5e097024 100644 --- a/packages/frontile/src/components/forms/field.gts +++ b/packages/frontile/src/components/forms/field.gts @@ -1,7 +1,8 @@ import Component from '@glimmer/component'; import { hash } from '@ember/helper'; -import { action, get } from '@ember/object'; +import { action } from '@ember/object'; import { debounce } from '@ember/runloop'; +import { getPath } from '../../utils/get-path'; import Checkbox from './checkbox'; import CheckboxGroup from './checkbox-group'; import DateInput from './date-input'; @@ -159,12 +160,11 @@ class Field< /** * Returns the current value for the field from formData. * Supports both flat and dotted field names (e.g., 'email' or 'profile.email'). - * Uses Ember's get() which handles both flat keys and dotted paths. */ get fieldValue() { if (!this.args.formData) return undefined; - return get(this.args.formData, this.args.name); + return getPath(this.args.formData, this.args.name); } /** diff --git a/packages/frontile/src/utils/get-path.ts b/packages/frontile/src/utils/get-path.ts new file mode 100644 index 000000000..9d2808758 --- /dev/null +++ b/packages/frontile/src/utils/get-path.ts @@ -0,0 +1,28 @@ +import { isUnsafeKey } from '../-private/unsafe-keys'; + +/** + * Reads a dotted path (`'profile.email'`) from plain data, returning + * `undefined` as soon as a segment is missing or lands on a non-object. + * + * Replaces `get` from `@ember/object` for form data. Only own properties are + * read, and unsafe segments (`__proto__`, `constructor`, `prototype`) are + * refused, because field names can come from untrusted schemas. + */ +export function getPath(data: unknown, path: string): unknown { + let current: unknown = data; + + for (const key of path.split('.')) { + if ( + current === null || + typeof current !== 'object' || + isUnsafeKey(key) || + !Object.prototype.hasOwnProperty.call(current, key) + ) { + return undefined; + } + + current = (current as Record)[key]; + } + + return current; +} diff --git a/packages/frontile/src/utils/nested-data.ts b/packages/frontile/src/utils/nested-data.ts index 85a4529e3..bac448666 100644 --- a/packages/frontile/src/utils/nested-data.ts +++ b/packages/frontile/src/utils/nested-data.ts @@ -3,6 +3,8 @@ * These functions convert between nested objects and flat dotted-path notation. */ +import { isUnsafeKey } from '../-private/unsafe-keys'; + /** * `File` and `FileList` are browser globals. `FileList` in particular has no * counterpart in Node, so a bare `value instanceof FileList` is a hard @@ -28,37 +30,6 @@ function isPlainObject(value: unknown): value is Record { ); } -/** - * Path segments that are refused as object keys. - * - * Field names come from the `name` attribute of the form's controls, which an - * app may render from a server-supplied schema, a CMS, or URL state — so they - * are untrusted input. - * - * `__proto__` is the real vector: `isPlainObject(Object.prototype)` is `true`, - * so when the walk below reached a `__proto__` segment it accepted - * `Object.prototype` as an already-existing nested object instead of creating a - * fresh one, and the final assignment wrote onto every object in the - * application. - * - * `constructor` and `prototype` do not reach `Object.prototype` through the - * walk as it is written — `current['constructor']` is a function, which - * `isPlainObject` rejects, so the walk shadows it with a fresh own key. They - * are refused anyway, as defense in depth: it keeps form data from shadowing - * those names, and it means a later change to the walk (or an intermediate that - * is a plain object with a `constructor` of its own) cannot quietly turn them - * into live vectors. - */ -const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; - -/** - * Checks a single path segment. Callers refuse the whole entry rather than - * substituting a safe key, so no partially-built path is left behind. - */ -function isUnsafeKey(key: string): boolean { - return UNSAFE_KEYS.includes(key); -} - /** * Checks whether a dotted path is safe to materialize. A single unsafe segment * anywhere in the path poisons the whole path, so the entry is refused wholesale diff --git a/test-app/tests/unit/forms/utils/get-path-test.ts b/test-app/tests/unit/forms/utils/get-path-test.ts new file mode 100644 index 000000000..7748c354d --- /dev/null +++ b/test-app/tests/unit/forms/utils/get-path-test.ts @@ -0,0 +1,60 @@ +import { module, test } from 'qunit'; +import { getPath } from 'frontile/utils/get-path'; + +module('Unit | Forms | Utils | getPath', function () { + test('reads a flat key', function (assert) { + assert.strictEqual( + getPath({ email: 'a@example.com' }, 'email'), + 'a@example.com' + ); + }); + + test('reads a dotted path through nested objects', function (assert) { + const data = { user: { profile: { email: 'a@example.com' } } }; + + assert.strictEqual(getPath(data, 'user.profile.email'), 'a@example.com'); + assert.deepEqual(getPath(data, 'user.profile'), { email: 'a@example.com' }); + }); + + test('returns falsy leaf values as-is', function (assert) { + const data = { a: { zero: 0, off: false, empty: '', nothing: null } }; + + assert.strictEqual(getPath(data, 'a.zero'), 0); + assert.false(getPath(data, 'a.off')); + assert.strictEqual(getPath(data, 'a.empty'), ''); + assert.strictEqual(getPath(data, 'a.nothing'), null); + }); + + test('returns undefined for a missing key or intermediate', function (assert) { + const data = { user: { name: 'Ada' } }; + + assert.strictEqual(getPath(data, 'missing'), undefined); + assert.strictEqual(getPath(data, 'user.missing'), undefined); + assert.strictEqual(getPath(data, 'missing.deeper.still'), undefined); + }); + + test('returns undefined when an intermediate is null or a primitive', function (assert) { + const data = { user: null, count: 3 } as Record; + + assert.strictEqual(getPath(data, 'user.name'), undefined); + assert.strictEqual(getPath(data, 'count.toFixed'), undefined); + }); + + test('reads array indices', function (assert) { + assert.strictEqual(getPath({ tags: ['a', 'b'] }, 'tags.1'), 'b'); + }); + + test('refuses unsafe segments instead of reaching the prototype', function (assert) { + const data = { user: { name: 'Ada' } }; + + assert.strictEqual(getPath(data, '__proto__'), undefined); + assert.strictEqual(getPath(data, 'user.__proto__'), undefined); + assert.strictEqual(getPath(data, 'constructor'), undefined); + assert.strictEqual(getPath(data, 'user.constructor.prototype'), undefined); + }); + + test('does not read inherited properties', function (assert) { + assert.strictEqual(getPath({}, 'toString'), undefined); + assert.strictEqual(getPath({ a: {} }, 'a.hasOwnProperty'), undefined); + }); +}); From a72362caf78bb7c6210f0c8db0c1111af9267f80 Mon Sep 17 00:00:00 2001 From: Josemar Luedke Date: Fri, 25 Sep 2026 11:04:18 -0700 Subject: [PATCH 2/4] refactor: replace @action with arrow-function class fields in packages Follows RFC 1045, which stops teaching @action in favour of arrow functions assigned to class fields. The co-located component docs are converted too, because Docfy renders their code fences as live demos and they are what consumers copy. No action is overridden through super, and no class-field initializer reads an action declared below it, so the conversion is behaviour- preserving; changeset-form's validate overrides stay working because base and subclasses are both fields. Co-Authored-By: Claude Opus 5.5 --- .../components/changeset-form/fields/base.ts | 6 +- .../changeset-form/fields/checkbox-group.gts | 9 +- .../changeset-form/fields/checkbox.gts | 9 +- .../changeset-form/fields/input.gts | 5 +- .../changeset-form/fields/radio-group.gts | 6 +- .../changeset-form/fields/radio.gts | 6 +- .../changeset-form/fields/select.gts | 21 ++- .../changeset-form/fields/textarea.gts | 5 +- .../src/components/changeset-form/index.gts | 13 +- .../components/form-checkbox-demo/usage.md | 5 +- .../form-checkbox-group-demo/usage.md | 9 +- .../src/components/form-checkbox-group.gts | 5 +- .../src/components/form-checkbox.gts | 5 +- .../src/components/form-field/checkbox.gts | 5 +- .../src/components/form-field/input.gts | 9 +- .../src/components/form-field/radio.gts | 5 +- .../src/components/form-field/textarea.gts | 9 +- .../src/components/form-input-demo/hint.md | 5 +- .../components/form-input-demo/no-label.md | 5 +- .../src/components/form-input-demo/simple.md | 5 +- .../components/form-input-demo/with-button.md | 9 +- .../src/components/form-input.gts | 9 +- .../src/components/form-radio-demo/usage.md | 5 +- .../components/form-radio-group-demo/usage.md | 5 +- .../src/components/form-radio-group.gts | 5 +- .../src/components/form-radio.gts | 5 +- .../components/form-select-demo/multiple.md | 5 +- .../src/components/form-select-demo/usage.md | 5 +- .../src/components/form-select.gts | 25 ++- .../components/form-textarea-demo/usage.md | 5 +- packages/frontile/src/-private/timer.ts | 13 +- .../src/components/buttons/button-group.md | 6 +- .../src/components/buttons/close-button.gts | 9 +- .../src/components/buttons/toggle-button.md | 12 +- .../src/components/collections/dropdown.md | 109 +++++-------- .../src/components/collections/listbox.md | 64 +++----- .../components/collections/listbox/item.gts | 11 +- .../components/collections/simple-table.md | 11 +- .../src/components/collections/table.md | 19 +-- .../components/collections/table/checkbox.gts | 5 +- .../collections/table/column-visibility.gts | 6 +- .../src/components/forms/checkbox-group.md | 38 ++--- .../src/components/forms/checkbox.gts | 9 +- .../frontile/src/components/forms/checkbox.md | 22 +-- .../frontile/src/components/forms/field.gts | 16 +- .../frontile/src/components/forms/field.md | 6 +- .../frontile/src/components/forms/form.gts | 29 ++-- .../src/components/forms/input-otp.gts | 21 ++- .../frontile/src/components/forms/input.gts | 17 +-- .../src/components/forms/native-select.gts | 11 +- .../frontile/src/components/forms/radio.gts | 9 +- .../frontile/src/components/forms/select.md | 11 +- .../frontile/src/components/forms/switch.md | 11 +- .../src/components/forms/textarea.gts | 13 +- .../src/components/overlays/drawer.md | 144 ++++++++---------- .../frontile/src/components/overlays/modal.md | 104 ++++++------- .../src/components/overlays/overlay.gts | 19 +-- .../src/components/overlays/overlay.md | 96 ++++++------ .../src/components/utilities/avatar.gts | 6 +- 59 files changed, 442 insertions(+), 630 deletions(-) diff --git a/packages/changeset-form/src/components/changeset-form/fields/base.ts b/packages/changeset-form/src/components/changeset-form/fields/base.ts index d8dd35a76..24dc60f62 100644 --- a/packages/changeset-form/src/components/changeset-form/fields/base.ts +++ b/packages/changeset-form/src/components/changeset-form/fields/base.ts @@ -1,7 +1,6 @@ import Component from '@glimmer/component'; import type { BufferedChangeset } from 'ember-changeset/types'; import { assert } from '@ember/debug'; -import { action } from '@ember/object'; import { later } from '@ember/runloop'; import type Owner from '@ember/owner'; @@ -66,8 +65,7 @@ export default class ChangesetFormFieldsBase< }, []); } - @action - async validate(): Promise { + validate = async (): Promise => { later( this, () => { @@ -75,5 +73,5 @@ export default class ChangesetFormFieldsBase< }, 1 ); - } + }; } diff --git a/packages/changeset-form/src/components/changeset-form/fields/checkbox-group.gts b/packages/changeset-form/src/components/changeset-form/fields/checkbox-group.gts index 86ffa8957..94bee54f2 100644 --- a/packages/changeset-form/src/components/changeset-form/fields/checkbox-group.gts +++ b/packages/changeset-form/src/components/changeset-form/fields/checkbox-group.gts @@ -1,6 +1,5 @@ import Component from '@glimmer/component'; import { assert } from '@ember/debug'; -import { action } from '@ember/object'; import ChangesetFormFieldsCheckbox from './checkbox'; import FormCheckboxGroup, { type FormCheckboxGroupArgs @@ -70,19 +69,19 @@ export default class ChangesetFormFieldsCheckboxGroup extends Component { + validate = async (): Promise => { if (this.args.groupName) { await this.args.changeset.validate(this.args.groupName); } - } + }; - @action handleChange(value: unknown, event: Event): void { + handleChange = (value: unknown, event: Event): void => { this.validate(); if (typeof this.args.onChange === 'function') { this.args.onChange(value, event); } - } + };