Skip to content

Commit 05aa5e4

Browse files
authored
fix: backport various fixes to 5.3 (#9225)
* fix: JSONAPISerializer should not reify empty records (#8934) * fix: JSONAPISerializer should not reify empty records * fix lint * fix: url configuration should respect / for host and error more meaningfully when invalid (#9164) * fix: url configuration should respect / for host and error more meaningfully when invalid * fix prettier * fix: keep a backreference for previously merged identifiers (#9183) * fix: keep a backreference for previously merged identifiers * update tests
1 parent f3d2b74 commit 05aa5e4

File tree

8 files changed

+283
-19
lines changed

8 files changed

+283
-19
lines changed

.vscode/settings.json

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"eslint.format.enable": true,
3+
"eslint.workingDirectories": [{ "mode": "auto" }, "packages/*", "tests/*"],
4+
"eslint.options": { "reportUnusedDisableDirectives": "error" },
5+
"editor.codeActionsOnSave": {
6+
"source.fixAll.eslint": "explicit"
7+
},
8+
"files.associations": {
9+
"turbo.json": "jsonc"
10+
},
11+
"editor.defaultFormatter": "esbenp.prettier-vscode",
12+
"editor.formatOnSave": true,
13+
"[javascript]": {
14+
"editor.defaultFormatter": "esbenp.prettier-vscode",
15+
"editor.formatOnSave": true
16+
},
17+
"[typescript]": {
18+
"editor.defaultFormatter": "esbenp.prettier-vscode",
19+
"editor.formatOnSave": true
20+
},
21+
"[handlebars]": {
22+
"editor.defaultFormatter": "esbenp.prettier-vscode",
23+
"editor.formatOnSave": true
24+
},
25+
"[json]": {
26+
"editor.defaultFormatter": "esbenp.prettier-vscode",
27+
"editor.formatOnSave": true
28+
},
29+
"[glimmer-js]": {
30+
"editor.defaultFormatter": "esbenp.prettier-vscode",
31+
"editor.formatOnSave": true
32+
},
33+
"[glimmer-ts]": {
34+
"editor.defaultFormatter": "esbenp.prettier-vscode",
35+
"editor.formatOnSave": true
36+
},
37+
"[markdown]": {
38+
"editor.detectIndentation": false,
39+
"editor.insertSpaces": false,
40+
"editor.tabSize": 4
41+
},
42+
"typescript.preferences.importModuleSpecifier": "project-relative"
43+
}

packages/legacy-compat/src/legacy-network-handler/snapshot.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,13 @@ export default class Snapshot implements Snapshot {
148148
@type {Model}
149149
@public
150150
*/
151-
get record(): RecordInstance {
152-
return this._store._instanceCache.getRecord(this.identifier);
151+
get record(): RecordInstance | null {
152+
const record = this._store.peekRecord(this.identifier);
153+
assert(
154+
`Record ${this.identifier.type} ${this.identifier.id} (${this.identifier.lid}) is not yet loaded and thus cannot be accessed from the Snapshot during serialization`,
155+
record !== null
156+
);
157+
return record;
153158
}
154159

155160
get _attributes(): Record<string, unknown> {

packages/request-utils/src/index.ts

+43-5
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export interface BuildURLConfig {
5252
namespace: string | null;
5353
}
5454

55-
let CONFIG: BuildURLConfig = {
55+
const CONFIG: BuildURLConfig = {
5656
host: '',
5757
namespace: '',
5858
};
@@ -64,7 +64,13 @@ let CONFIG: BuildURLConfig = {
6464
* These values may still be overridden by passing
6565
* them to buildBaseURL directly.
6666
*
67-
* This method may be called as many times as needed
67+
* This method may be called as many times as needed.
68+
* host values of `''` or `'/'` are equivalent.
69+
*
70+
* Except for the value of `/` as host, host should not
71+
* end with `/`.
72+
*
73+
* namespace should not start or end with a `/`.
6874
*
6975
* ```ts
7076
* type BuildURLConfig = {
@@ -73,6 +79,17 @@ let CONFIG: BuildURLConfig = {
7379
* }
7480
* ```
7581
*
82+
* Example:
83+
*
84+
* ```ts
85+
* import { setBuildURLConfig } from '@ember-data/request-utils';
86+
*
87+
* setBuildURLConfig({
88+
* host: 'https://api.example.com',
89+
* namespace: 'api/v1'
90+
* });
91+
* ```
92+
*
7693
* @method setBuildURLConfig
7794
* @static
7895
* @public
@@ -81,7 +98,27 @@ let CONFIG: BuildURLConfig = {
8198
* @returns void
8299
*/
83100
export function setBuildURLConfig(config: BuildURLConfig) {
84-
CONFIG = config;
101+
assert(`setBuildURLConfig: You must pass a config object`, config);
102+
assert(
103+
`setBuildURLConfig: You must pass a config object with a 'host' or 'namespace' property`,
104+
'host' in config || 'namespace' in config
105+
);
106+
107+
CONFIG.host = config.host || '';
108+
CONFIG.namespace = config.namespace || '';
109+
110+
assert(
111+
`buildBaseURL: host must NOT end with '/', received '${CONFIG.host}'`,
112+
CONFIG.host === '/' || !CONFIG.host.endsWith('/')
113+
);
114+
assert(
115+
`buildBaseURL: namespace must NOT start with '/', received '${CONFIG.namespace}'`,
116+
!CONFIG.namespace.startsWith('/')
117+
);
118+
assert(
119+
`buildBaseURL: namespace must NOT end with '/', received '${CONFIG.namespace}'`,
120+
!CONFIG.namespace.endsWith('/')
121+
);
85122
}
86123

87124
export interface FindRecordUrlOptions {
@@ -312,8 +349,9 @@ export function buildBaseURL(urlOptions: UrlOptions): string {
312349
assert(`buildBaseURL: idPath must NOT start with '/', received '${idPath}'`, !idPath.startsWith('/'));
313350
assert(`buildBaseURL: idPath must NOT end with '/', received '${idPath}'`, !idPath.endsWith('/'));
314351

315-
const url = [host === '/' ? '' : host, namespace, resourcePath, idPath, fieldPath].filter(Boolean).join('/');
316-
return host ? url : `/${url}`;
352+
const hasHost = host !== '' && host !== '/';
353+
const url = [hasHost ? host : '', namespace, resourcePath, idPath, fieldPath].filter(Boolean).join('/');
354+
return hasHost ? url : `/${url}`;
317355
}
318356

319357
type SerializablePrimitive = string | number | boolean | null;

packages/store/src/-private/caches/identifier-cache.ts

+32-5
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ type StableCache = {
8484
resources: IdentifierMap;
8585
documents: Map<string, StableDocumentIdentifier>;
8686
resourcesByType: TypeMap;
87+
polymorphicLidBackMap: Map<string, string[]>;
8788
};
8889

8990
export type KeyInfoMethod = (resource: unknown, known: StableRecordIdentifier | null) => KeyInfo;
@@ -251,6 +252,7 @@ export class IdentifierCache {
251252
resources: new Map<string, StableRecordIdentifier>(),
252253
resourcesByType: Object.create(null) as TypeMap,
253254
documents: new Map<string, StableDocumentIdentifier>(),
255+
polymorphicLidBackMap: new Map<string, string[]>(),
254256
};
255257
}
256258

@@ -560,16 +562,33 @@ export class IdentifierCache {
560562
const kept = this._merge(identifier, existingIdentifier, data);
561563
const abandoned = kept === identifier ? existingIdentifier : identifier;
562564

565+
// get any backreferences before forgetting this identifier, as it will be removed from the cache
566+
// and we will no longer be able to find them
567+
const abandonedBackReferences = this._cache.polymorphicLidBackMap.get(abandoned.lid);
568+
// delete the backreferences for the abandoned identifier so that forgetRecordIdentifier
569+
// does not try to remove them.
570+
if (abandonedBackReferences) this._cache.polymorphicLidBackMap.delete(abandoned.lid);
571+
563572
// cleanup the identifier we no longer need
564573
this.forgetRecordIdentifier(abandoned);
565574

566-
// ensure a secondary cache entry for this id for the identifier we do keep
567-
// keyOptions.id.set(newId, kept);
575+
// ensure a secondary cache entry for the original lid for the abandoned identifier
576+
this._cache.resources.set(abandoned.lid, kept);
577+
578+
// backReferences let us know which other identifiers are pointing at this identifier
579+
// so we can delete them later if we forget this identifier
580+
const keptBackReferences = this._cache.polymorphicLidBackMap.get(kept.lid) ?? [];
581+
keptBackReferences.push(abandoned.lid);
568582

569-
// ensure a secondary cache entry for this id for the abandoned identifier's type we do keep
570-
// let baseKeyOptions = getTypeIndex(this._cache.resourcesByType, existingIdentifier.type);
571-
// baseKeyOptions.id.set(newId, kept);
583+
// update the backreferences from the abandoned identifier to be for the kept identifier
584+
if (abandonedBackReferences) {
585+
abandonedBackReferences.forEach((lid) => {
586+
keptBackReferences.push(lid);
587+
this._cache.resources.set(lid, kept);
588+
});
589+
}
572590

591+
this._cache.polymorphicLidBackMap.set(kept.lid, keptBackReferences);
573592
return kept;
574593
}
575594

@@ -596,6 +615,14 @@ export class IdentifierCache {
596615
this._cache.resources.delete(identifier.lid);
597616
typeSet.lid.delete(identifier.lid);
598617

618+
const backReferences = this._cache.polymorphicLidBackMap.get(identifier.lid);
619+
if (backReferences) {
620+
backReferences.forEach((lid) => {
621+
this._cache.resources.delete(lid);
622+
});
623+
this._cache.polymorphicLidBackMap.delete(identifier.lid);
624+
}
625+
599626
if (DEBUG) {
600627
identifier[DEBUG_STALE_CACHE_OWNER] = identifier[CACHE_OWNER];
601628
}

tests/main/tests/integration/identifiers/configuration-test.ts

+40-2
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ module('Integration | Identifiers - configuration', function (hooks) {
383383
if (bucket !== 'record') {
384384
throw new Error('Test cannot generate an lid for a non-record');
385385
}
386+
if (typeof resource === 'object' && resource !== null && 'lid' in resource && typeof resource.lid === 'string') {
387+
generateLidCalls++;
388+
return resource.lid;
389+
}
386390
if (typeof resource !== 'object' || resource === null || !('type' in resource)) {
387391
throw new Error('Test cannot generate an lid for a non-object');
388392
}
@@ -397,7 +401,7 @@ module('Integration | Identifiers - configuration', function (hooks) {
397401

398402
let testMethod = (identifier) => {
399403
forgetMethodCalls++;
400-
assert.strictEqual(expectedIdentifier, identifier, `We forgot the expected identifier ${expectedIdentifier}`);
404+
assert.strictEqual(identifier, expectedIdentifier, `We forgot the expected identifier ${expectedIdentifier}`);
401405
};
402406

403407
setIdentifierForgetMethod((identifier) => {
@@ -433,7 +437,11 @@ module('Integration | Identifiers - configuration', function (hooks) {
433437
const finalUserByIdIdentifier = recordIdentifierFor(userById);
434438

435439
assert.strictEqual(generateLidCalls, 2, 'We generated no new lids when we looked up the originals');
436-
assert.strictEqual(store.identifierCache._cache.resources.size, 1, 'We now have only 1 identifier in the cache');
440+
assert.strictEqual(
441+
store.identifierCache._cache.resources.size,
442+
2,
443+
'We keep a back reference identifier in the cache'
444+
);
437445
assert.strictEqual(forgetMethodCalls, 1, 'We abandoned an identifier');
438446

439447
assert.notStrictEqual(
@@ -452,6 +460,36 @@ module('Integration | Identifiers - configuration', function (hooks) {
452460
'We are using the identifier by id for the result of findRecord with username'
453461
);
454462

463+
const recordA = store.peekRecord({ lid: finalUserByUsernameIdentifier.lid });
464+
const recordB = store.peekRecord({ lid: finalUserByIdIdentifier.lid });
465+
const recordC = store.peekRecord({ lid: originalUserByUsernameIdentifier.lid });
466+
const recordD = store.peekRecord('user', '@runspired');
467+
const recordE = store.peekRecord('user', '1');
468+
469+
assert.strictEqual(recordA, recordB, 'We have a single record for both identifiers');
470+
assert.strictEqual(recordA, recordC, 'We have a single record for both identifiers');
471+
assert.strictEqual(recordA, recordD, 'We have a single record for both identifiers');
472+
assert.strictEqual(recordA, recordE, 'We have a single record for both identifiers');
473+
474+
const regeneratedIdentifier = store.identifierCache.getOrCreateRecordIdentifier({
475+
lid: finalUserByUsernameIdentifier.lid,
476+
});
477+
const regeneratedIdentifier2 = store.identifierCache.getOrCreateRecordIdentifier({
478+
id: '@runspired',
479+
type: 'user',
480+
});
481+
assert.strictEqual(regeneratedIdentifier, finalUserByUsernameIdentifier, 'We regenerate the same identifier');
482+
assert.strictEqual(regeneratedIdentifier2, finalUserByUsernameIdentifier, 'We regenerate the same identifier');
483+
484+
expectedIdentifier = finalUserByIdIdentifier;
485+
store.unloadRecord(recordA);
486+
await settled();
487+
assert.strictEqual(
488+
store.identifierCache._cache.resources.size,
489+
0,
490+
'We have no identifiers or backreferences in the cache'
491+
);
492+
455493
// end test before store teardown
456494
testMethod = () => {};
457495
});

tests/main/tests/integration/identifiers/scenarios-test.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -472,12 +472,14 @@ module('Integration | Identifiers - scenarios', function (hooks) {
472472

473473
// ensure we truly are in a good state internally
474474
const lidCache = store.identifierCache._cache.resources;
475-
const lids = [...lidCache.values()];
476-
assert.strictEqual(
477-
lidCache.size,
478-
1,
479-
`We only have the lid '${identifierByUsername.lid}' in ['${lids.join("', '")}']`
475+
assert.strictEqual(lidCache.size, 2, `We should have both lids in the cache still since one is a backreference`);
476+
assert.deepEqual(
477+
[...lidCache.keys()],
478+
['remote:user:1:9001', 'remote:user:@runspired:9000'],
479+
'We have the expected keys'
480480
);
481+
const lids = [...lidCache.values()];
482+
assert.arrayStrictEquals(lids, [identifierById, identifierById], 'We have the expected values');
481483

482484
// ensure we still use secondary caching for @runspired post-merging of the identifiers
483485
const recordByUsernameAgain = (await store.findRecord('user', '@runspired')) as User;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { settled } from '@ember/test-helpers';
2+
3+
import { module, test } from 'qunit';
4+
5+
import type Store from 'ember-data/store';
6+
import { setupTest } from 'ember-qunit';
7+
8+
import Model, { attr } from '@ember-data/model';
9+
import { recordIdentifierFor } from '@ember-data/store';
10+
11+
class Person extends Model {
12+
@attr declare name: string;
13+
}
14+
class Employee extends Person {}
15+
16+
module('integration/records/polymorphic-find-record - Polymorphic findRecord', function (hooks) {
17+
setupTest(hooks);
18+
19+
test('when findRecord with abstract type returns concrete type', async function (assert) {
20+
this.owner.register('model:person', Person);
21+
this.owner.register('model:employee', Employee);
22+
23+
const store = this.owner.lookup('service:store') as Store;
24+
const adapter = store.adapterFor('application');
25+
26+
adapter.findRecord = () => {
27+
return Promise.resolve({
28+
data: {
29+
id: '1',
30+
type: 'employee',
31+
attributes: {
32+
name: 'Rey Skybarker',
33+
},
34+
},
35+
});
36+
};
37+
38+
const person = (await store.findRecord('person', '1')) as Employee;
39+
assert.ok(person instanceof Employee, 'record is an instance of Employee');
40+
assert.strictEqual(person.name, 'Rey Skybarker', 'name is correct');
41+
assert.strictEqual(recordIdentifierFor(person).type, 'employee', 'identifier has the concrete type');
42+
43+
const employee = store.peekRecord('employee', '1');
44+
const person2 = store.peekRecord('person', '1');
45+
assert.strictEqual(employee, person, 'peekRecord returns the same instance for concrete type');
46+
assert.strictEqual(person2, person, 'peekRecord returns the same instance for abstract type');
47+
assert.strictEqual(store.identifierCache._cache.resources.size, 2, 'identifier cache contains backreferences');
48+
49+
person.unloadRecord();
50+
await settled();
51+
assert.strictEqual(store.identifierCache._cache.resources.size, 0, 'identifier cache is empty');
52+
});
53+
});

0 commit comments

Comments
 (0)