From f988ba6c53510c03154b71d49c32d5f09986cfd3 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:47:31 -0400 Subject: [PATCH 1/2] Remove ember-modify-based-class-resource from the table The Table was a class-based Resource. Two bundled copies of ember-modify-based-class-resource register the same usable type twice, which throws "type may not overlap with an existing usable" and blanks the page. The table needs lifetime linking, not resources, so it is a plain class now: `link(this, parent)` gives it the parent's owner and destruction, and every value is derived from the config it was constructed with. Preferences move from the `modify` hook to a cached getter, so a `preferences` thunk that reads tracked data restores again on its own. Plugin instances now survive that, instead of being thrown away whenever any consumed arg changed. Breaking: - `headlessTable` requires a parent object. The `@use headlessTable({...})` form is gone. - `table.args` is gone. Read `table.config`. - The scroll container is no longer reset when the config changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../templates/1-get-started/upgrading.gjs.md | 41 +++++++ pnpm-lock.yaml | 28 ----- table/package.json | 2 - table/src/-private/js-helper.ts | 53 +++------ table/src/-private/table.ts | 110 +++++++++--------- test-app/tests/unit/table-test.ts | 5 +- 6 files changed, 113 insertions(+), 126 deletions(-) create mode 100644 docs-app/src/templates/1-get-started/upgrading.gjs.md diff --git a/docs-app/src/templates/1-get-started/upgrading.gjs.md b/docs-app/src/templates/1-get-started/upgrading.gjs.md new file mode 100644 index 00000000..57c97332 --- /dev/null +++ b/docs-app/src/templates/1-get-started/upgrading.gjs.md @@ -0,0 +1,41 @@ +# Upgrading + +## To 4.0 + +The table is a plain class now. It was a `Resource` from `ember-modify-based-class-resource`, and that package is no longer a dependency. + +Two applications that each bundled a copy of `ember-modify-based-class-resource` crashed with `type may not overlap with an existing usable`. That failure is gone. + +### `headlessTable` needs a parent object + +Pass the object that owns the table as the first argument. The table is destroyed with that object, and it uses that object's owner. + +```js +// before +class MyImplementation { + @use table = headlessTable({ + columns: () => [], + data: () => [], + }); +} + +// after +class MyImplementation { + table = headlessTable(this, { + columns: () => [], + data: () => [], + }); +} +``` + +The two-argument form did not change. If you already write `headlessTable(this, { ... })`, you have no work to do. + +### The scroll position is no longer reset for you + +The table reset the scroll container to the top every time the configuration changed. It does not do this now. + +If you want that behavior, call `table.resetScrollContainer()` from your own code. + +### `table.args` is gone + +Read `table.config` instead. It returns the options object that you passed to `headlessTable`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a986fe9..d7eb1bf0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,12 +253,6 @@ importers: ember-modifier: specifier: ^4.3.0 version: 4.3.0(@babel/core@7.29.0) - ember-modify-based-class-resource: - specifier: ^1.1.2 - version: 1.1.2(24a2df5f3e45b9f9d6498ae47b2fbae9) - ember-resources: - specifier: ^7.0.7 - version: 7.0.7(972e7a9dd19717b828f7a881726685f7) reactiveweb: specifier: ^1.9.1 version: 1.9.1(e6a5f41cb5e16588d59169bb30fdd2b0) @@ -3785,15 +3779,6 @@ packages: ember-modifier@4.3.0: resolution: {integrity: sha512-O0rirSLQbGg0VJ/NqoQ4uN1bh2iAekZC/Ykma+FkjCM2ofrO38u+d8n3+AK6uVWeMJmogGX2KL+Is5fofoInJg==} - ember-modify-based-class-resource@1.1.2: - resolution: {integrity: sha512-eruTLd+aMzrXaLDInQSYv3zTcG5+0Ttn69W31JtOp6nSR3T22ET3q+6zZjCVNxmlDufSAu6lSFd9EAtKaazCdA==} - peerDependencies: - '@glimmer/component': ^1.1.2 || >= 2.0.0 - ember-resources: '>= 6.4.0' - peerDependenciesMeta: - '@glimmer/component': - optional: true - ember-page-title@9.0.3: resolution: {integrity: sha512-fedRHUsvq8tIZgOii8jTrfAyeq+la/9H5eAzhNNwEyzo7nDMmqK2SxsyBUGXprd8fOacsPabLlzlucMi/4mUpA==} engines: {node: 16.* || >= 18} @@ -12499,19 +12484,6 @@ snapshots: - '@babel/core' - supports-color - ember-modify-based-class-resource@1.1.2(24a2df5f3e45b9f9d6498ae47b2fbae9): - dependencies: - '@babel/runtime': 7.28.6 - '@embroider/addon-shim': 1.10.2 - '@embroider/macros': 1.20.1(882cf82caec78f226fcbd647b89f3bbe) - ember-resources: 7.0.7(972e7a9dd19717b828f7a881726685f7) - optionalDependencies: - '@glimmer/component': 2.0.0 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - ember-page-title@9.0.3: dependencies: '@embroider/addon-shim': 1.10.2 diff --git a/table/package.json b/table/package.json index 1dbc2bc9..e7546a9a 100644 --- a/table/package.json +++ b/table/package.json @@ -126,8 +126,6 @@ "@embroider/addon-shim": "^1.10.2", "@embroider/macros": "^1.19.7", "ember-modifier": "^4.3.0", - "ember-modify-based-class-resource": "^1.1.2", - "ember-resources": "^7.0.7", "reactiveweb": "^1.9.1", "tracked-built-ins": "^4.1.0" }, diff --git a/table/src/-private/js-helper.ts b/table/src/-private/js-helper.ts index ebc3de96..de3fffc6 100644 --- a/table/src/-private/js-helper.ts +++ b/table/src/-private/js-helper.ts @@ -1,34 +1,16 @@ +import { assert } from '@ember/debug'; + import { Table } from './table.ts'; import type { TableConfig } from './interfaces'; -type Args = - | [destroyable: object, options: TableConfig] - | [options: TableConfig]; - /** * Represents a UI-less version of a table * * _For use for building tables in ui frameworks_. * - * @example - * ```js - * import { use } from 'ember-resources'; - * import { headlessTable } '@universal-ember/table'; - * - * class MyImplementation { - * @use table = headlessTable({ - * // your config here - * }) - * } - * ``` - */ -export function headlessTable(options: TableConfig): Table; - -/** - * Represents a UI-less version of a table - * - * _For use for building tables in ui frameworks_. + * The first argument is the object that owns the table. + * The table is destroyed with that object, and uses that object's owner. * * @example * ```js @@ -43,23 +25,14 @@ export function headlessTable(options: TableConfig): Table; * */ export function headlessTable( - destroyable: object, + parent: object, options: TableConfig, -): Table; - -export function headlessTable(...args: Args): Table { - if (args.length === 2) { - const [destroyable, options] = args; - - /** - * If any "root level" config changes, we need to throw-away everything. - * otherwise individual-property reactivity can be managed on a per-property - * "thunk"-basis - */ - return Table.from>(destroyable, () => options); - } - - const [options] = args; - - return Table.from>(() => options); +): Table { + assert( + `headlessTable requires a parent object as the first argument, usually \`this\`. ` + + `The single-argument form was removed, because the table is no longer a Resource.`, + options, + ); + + return new Table(parent, options); } diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts index bceb8128..0beb12f7 100644 --- a/table/src/-private/table.ts +++ b/table/src/-private/table.ts @@ -5,7 +5,7 @@ import { guidFor } from '@ember/object/internals'; import { isDevelopingApp, macroCondition } from '@embroider/macros'; import { modifier } from 'ember-modifier'; -import { Resource } from 'ember-modify-based-class-resource'; +import { link } from 'reactiveweb/link'; import { map } from 'reactiveweb/map'; import { @@ -20,6 +20,7 @@ import { composeFunctionModifiers } from './utils.ts'; import type { BasePlugin, Plugin } from '../plugins/index.ts'; import type { Class } from './private-types.ts'; import type { Destructor, TableConfig } from './interfaces'; +import type Owner from '@ember/owner'; import { compatOwner } from './ember-compat.ts'; const getOwner = compatOwner.getOwner; @@ -30,18 +31,9 @@ const DEFAULT_COLUMN_CONFIG = { minWidth: 128, }; -interface Signature { - Named: TableConfig; -} - /** * Because the table is our entry-point object to all the table behaviors, * we need a stable way to know which table we have. - * Normally, this could be done with referential integrity / identity. - * However, due to how resources are implemented, if the consumer opts to - * not use the `@use` decorator, then proxies get involved. - * The proxies don't maintain instanceof checks, which may be a bug in - * ember-resources. */ export const TABLE_KEY = Symbol('__TABLE_KEY__'); export const TABLE_META_KEY = Symbol('__TABLE_META__'); @@ -54,7 +46,7 @@ const attachContainer = (element: Element, table: Table) => { table.scrollContainerElement = element; }; -export class Table extends Resource> { +export class Table { /** * @private */ @@ -91,45 +83,68 @@ export class Table extends Resource> { /** * @private - * - * Lazy way to delay consuming arguments until they are needed. */ - @tracked declare args: { named: Signature['Named'] }; + scrollContainerElement?: HTMLElement; + + #parent: object; + #config: TableConfig; + + constructor(parent: object, config: TableConfig) { + this.#parent = parent; + this.#config = config; + + /** + * The table is destroyed with the object that created it, + * and it uses that object's owner for its plugins. + */ + link(this, parent); + } /** * @private + * + * The owner is read from the parent on first use, because the parent + * can receive its owner after the table is created. For example, a class + * field runs before `setOwner` on a manually constructed object. */ - scrollContainerElement?: HTMLElement; + get #owner(): Owner | undefined { + const existing = getOwner(this); - /** - * Interact with, save, modify, etc the preferences for the table, - * plugins, columns, etc - */ - declare preferences: TablePreferences; + if (existing) return existing; + + const owner = getOwner(this.#parent); + + if (owner) setOwner(this, owner); + + return owner; + } /** * @private + * + * used by other private APIs */ - modify(_: [] | undefined, named: Signature['Named']) { - this.args = { named }; + get config(): TableConfig { + return this.#config; + } - const preferences = named?.preferences; + /** + * Interact with, save, modify, etc the preferences for the table, + * plugins, columns, etc + * + * When the `preferences` config is a function, the preferences are + * restored again every time the tracked data that the function reads changes. + */ + @cached + get preferences(): TablePreferences { + const config = this.#config.preferences; const { key = guidFor(this), adapter } = - (typeof preferences === 'function' ? preferences() : preferences) ?? {}; - - // only set the preferences once - if (!this.preferences) { - // TODO: when no key is present, - // use "local-storage" preferences. - // it does not make sense to use a guid in a user's preferences - this.preferences = new TablePreferences(key, adapter); - } else { - // subsequent updates to args - if (typeof preferences === 'function' && adapter) { - this.preferences.restore(adapter); - } - this.resetScrollContainer(); - } + (typeof config === 'function' ? config() : config) ?? {}; + + // TODO: when no key is present, + // use "local-storage" preferences. + // it does not make sense to use a guid in a user's preferences + return new TablePreferences(key, adapter); } /** @@ -186,12 +201,10 @@ export class Table extends Resource> { * @private * * For all configured plugins, instantiates each one. - * If the plugins argument changes to the Table (either directly or through - * headlessTable, all state is lost and re-created) */ @cached get plugins(): Plugin[] { - const plugins = normalizePluginsConfig(this.args.named?.plugins); + const plugins = normalizePluginsConfig(this.#config.plugins); verifyPlugins(plugins); @@ -202,7 +215,7 @@ export class Table extends Resource> { if (typeof PluginClass === 'function') { const plugin = new PluginClass(this); - const owner = getOwner(this); + const owner = this.#owner; assert( `The Table does not have an owner. cannot create a plugin without an owner`, @@ -239,18 +252,9 @@ export class Table extends Resource> { return result as unknown as Instance | undefined; } - /** - * @private - * - * used by other private APIs - */ - get config() { - return this.args.named; - } - rows = map(this, { data: () => { - const dataFn = this.args.named?.data; + const dataFn = this.#config.data; if (!dataFn) return []; @@ -261,7 +265,7 @@ export class Table extends Resource> { columns = map(this, { data: () => { - const configFn = this.args.named?.columns; + const configFn = this.#config.columns; if (!configFn) return []; diff --git a/test-app/tests/unit/table-test.ts b/test-app/tests/unit/table-test.ts index 24b18d87..61c396db 100644 --- a/test-app/tests/unit/table-test.ts +++ b/test-app/tests/unit/table-test.ts @@ -3,7 +3,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { headlessTable } from '@universal-ember/table'; -import { use } from 'ember-resources'; import type { ColumnConfig, @@ -29,9 +28,9 @@ function withTestDefaults( module('Unit | -private | table', function (hooks) { setupTest(hooks); - test('supports @use', async function (assert) { + test('supports an owner that is assigned after construction', async function (assert) { class TestObject { - @use table = headlessTable({ + table = headlessTable(this, { columns: () => [ { key: 'firstName', name: 'First name' }, { key: 'lastName', name: 'Last name' }, From b7e13647c0e7c67de09797ff9713991340fa907e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:57:12 -0400 Subject: [PATCH 2/2] Assert on the owner in one place The plugins getter no longer asserts. The owner getter does, because that is where the lookup happens. `link` cannot be the only source of the owner. A parent can receive its owner after the table is created, since a class field initializes before `setOwner` runs on a manually constructed object. 41 test setups do exactly that. Reading the owner from the parent on first use covers them. Co-Authored-By: Claude Opus 5 (1M context) --- table/src/-private/table.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/table/src/-private/table.ts b/table/src/-private/table.ts index 0beb12f7..25d8653d 100644 --- a/table/src/-private/table.ts +++ b/table/src/-private/table.ts @@ -103,18 +103,24 @@ export class Table { /** * @private * - * The owner is read from the parent on first use, because the parent - * can receive its owner after the table is created. For example, a class - * field runs before `setOwner` on a manually constructed object. + * `link` copies the owner over, but the parent can receive its owner + * after the table is created. A class field initializes before `setOwner` + * runs on a manually constructed object, so the owner is read from the + * parent on first use. */ - get #owner(): Owner | undefined { - const existing = getOwner(this); + get #owner(): Owner { + let owner = getOwner(this); - if (existing) return existing; + if (!owner) { + owner = getOwner(this.#parent); - const owner = getOwner(this.#parent); + assert( + `The Table does not have an owner. cannot create a plugin without an owner`, + owner, + ); - if (owner) setOwner(this, owner); + setOwner(this, owner); + } return owner; } @@ -215,13 +221,7 @@ export class Table { if (typeof PluginClass === 'function') { const plugin = new PluginClass(this); - const owner = this.#owner; - - assert( - `The Table does not have an owner. cannot create a plugin without an owner`, - owner, - ); - setOwner(plugin, owner); + setOwner(plugin, this.#owner); return plugin; }