Skip to content

Commit

Permalink
feat(private): reactive simple fields (#8948)
Browse files Browse the repository at this point in the history
  • Loading branch information
runspired authored Oct 2, 2023
1 parent 0b14fda commit d9b7fe1
Show file tree
Hide file tree
Showing 11 changed files with 561 additions and 15 deletions.
20 changes: 19 additions & 1 deletion packages/schema-record/src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import type Store from '@ember-data/store';
import type { StableRecordIdentifier } from "@ember-data/types/q/identifier";
import type { FieldSchema, SchemaService } from './schema';
import { Cache } from '@ember-data/types/q/cache';
import { Document } from '@ember-data/store/-private/document';
import { tracked } from '@glimmer/tracking';
import { Link, Links, SingleResourceRelationship } from '@ember-data/types/q/ember-data-json-api';
import { StoreRequestInput } from '@ember-data/store/-private/cache-handler';
import { Future } from '@ember-data/request';
import { DEBUG } from '@ember-data/env';
import { NotificationType } from '@ember-data/store/-private/managers/notification-manager';
import { addToTransaction, entangleSignal } from '@ember-data/tracking/-private';

export const Destroy = Symbol('Destroy');
export const RecordStore = Symbol('Store');
Expand Down Expand Up @@ -110,6 +111,7 @@ export class SchemaRecord {
declare [RecordStore]: Store;
declare [Identifier]: StableRecordIdentifier;
declare [Editable]: boolean;
declare ___notifications: unknown;

constructor(store: Store, identifier: StableRecordIdentifier, editable: boolean) {
this[RecordStore] = store;
Expand All @@ -120,6 +122,20 @@ export class SchemaRecord {
const cache = store.cache;
const fields = schema.fields(identifier);

const signals = new Map();
this.___notifications = store.notifications.subscribe(identifier, (_: StableRecordIdentifier, type: NotificationType, key?: string) => {
switch (type) {
case 'attributes':
if (key) {
const signal = signals.get(key);
if (signal) {
addToTransaction(signal);
}
}
break;
}
});

return new Proxy(this, {
get(target, prop, receiver) {
if (prop === Destroy) {
Expand All @@ -140,8 +156,10 @@ export class SchemaRecord {

switch (field.kind) {
case 'attribute':
entangleSignal(signals, this, field.name);
return computeAttribute(schema, cache, target, identifier, field, prop as string);
case 'resource':
entangleSignal(signals, this, field.name);
return computeResource(store, cache, target, identifier, field, prop as string);

case 'derived':
Expand Down
3 changes: 2 additions & 1 deletion packages/tracking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"dependencies": {
"@ember-data/private-build-infra": "workspace:5.5.0-alpha.10",
"@embroider/macros": "^1.13.1",
"ember-cli-babel": "^8.1.0"
"ember-cli-babel": "^8.1.0",
"@glimmer/validator": "^0.84.3"
},
"files": [
"addon-main.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/tracking/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default {
// You can augment this if you need to.
output: addon.output(),

external: ['@embroider/macros'],
external: ['@embroider/macros', '@glimmer/validator'],

plugins: [
// These are the modules that users should be able to import from your
Expand Down
81 changes: 70 additions & 11 deletions packages/tracking/src/-private.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { tagForProperty } from '@ember/-internals/metal';
import { consumeTag, dirtyTag } from '@glimmer/validator';

import { DEBUG } from '@ember-data/env';

/**
Expand All @@ -18,8 +21,8 @@ type OpaqueFn = (...args: unknown[]) => unknown;
type Tag = { ref: null; t: boolean };
type Transaction = {
cbs: Set<OpaqueFn>;
props: Set<Tag>;
sub: Set<Tag>;
props: Set<Tag | Signal>;
sub: Set<Tag | Signal>;
parent: Transaction | null;
};
let TRANSACTION: Transaction | null = null;
Expand All @@ -37,18 +40,26 @@ function createTransaction() {
TRANSACTION = transaction;
}

export function subscribe(obj: Tag): void {
export function subscribe(obj: Tag | Signal): void {
if (TRANSACTION) {
TRANSACTION.sub.add(obj);
} else if ('tag' in obj) {
// @ts-expect-error - we are using Ember's Tag not Glimmer's
consumeTag(obj.tag);
} else {
obj.ref;
}
}

function updateRef(obj: Tag): void {
function updateRef(obj: Tag | Signal): void {
if (DEBUG) {
try {
obj.ref = null;
if ('tag' in obj) {
// @ts-expect-error - we are using Ember's Tag not Glimmer's
dirtyTag(obj.tag);
} else {
obj.ref = null;
}
} catch (e: unknown) {
if (e instanceof Error) {
if (e.message.includes('You attempted to update `ref` on `Tag`')) {
Expand Down Expand Up @@ -97,7 +108,12 @@ function updateRef(obj: Tag): void {
throw e;
}
} else {
obj.ref = null;
if ('tag' in obj) {
// @ts-expect-error - we are using Ember's Tag not Glimmer's
dirtyTag(obj.tag);
} else {
obj.ref = null;
}
}
}

Expand All @@ -107,13 +123,18 @@ function flushTransaction() {
transaction.cbs.forEach((cb) => {
cb();
});
transaction.props.forEach((obj: Tag) => {
transaction.props.forEach((obj) => {
// mark this mutation as part of a transaction
obj.t = true;
updateRef(obj);
});
transaction.sub.forEach((obj: Tag) => {
obj.ref;
transaction.sub.forEach((obj) => {
if ('tag' in obj) {
// @ts-expect-error - we are using Ember's Tag not Glimmer's
consumeTag(obj.tag);
} else {
obj.ref;
}
});
}
async function untrack() {
Expand All @@ -125,14 +146,14 @@ async function untrack() {
transaction.cbs.forEach((cb) => {
cb();
});
transaction.props.forEach((obj: Tag) => {
transaction.props.forEach((obj) => {
// mark this mutation as part of a transaction
obj.t = true;
updateRef(obj);
});
}

export function addToTransaction(obj: Tag): void {
export function addToTransaction(obj: Tag | Signal): void {
if (TRANSACTION) {
TRANSACTION.props.add(obj);
} else {
Expand Down Expand Up @@ -213,3 +234,41 @@ export function memoTransact<T extends OpaqueFn>(method: T): (...args: unknown[]
return ret as ReturnType<T>;
};
}

interface Signal {
_debug_base?: string;
_debug_prop?: string;

t: boolean;
shouldReset: boolean;
tag: ReturnType<typeof tagForProperty>;
}

export function createSignal<T extends object, K extends keyof T & string>(obj: T, key: K): Signal {
const _signal: Signal = {
tag: tagForProperty(obj, key),
t: false,
shouldReset: false,
};

if (DEBUG) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-base-to-string
_signal._debug_base = obj.constructor?.name ?? obj.toString?.() ?? 'unknown';
_signal._debug_prop = key;
}

return _signal;
}

export function entangleSignal<T extends object, K extends keyof T & string>(
signals: Map<K, Signal>,
obj: T,
key: K
): void {
let signal = signals.get(key);
if (!signal) {
signal = createSignal(obj, key);
signals.set(key, signal);
}
subscribe(signal);
}
2 changes: 1 addition & 1 deletion packages/tracking/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { transact, memoTransact, untracked } from './-private';
export { transact, memoTransact, untracked, entangleSignal } from './-private';
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions tests/schema-record/tests/-utils/reactive-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, TestContext } from '@ember/test-helpers';
import Component from '@glimmer/component';

import type { FieldSchema } from '@warp-drive/schema-record/schema';

import { hbs } from 'ember-cli-htmlbars';

import type { ResourceRelationship } from '@ember-data/types/cache/relationship';

export async function reactiveContext<T extends object>(this: TestContext, record: T, fields: FieldSchema[]) {
const _fields: string[] = ['idCount', 'id', '$typeCount', '$type'];
fields.forEach((field) => {
_fields.push(field.name + 'Count');
_fields.push(field.name);
});

class ReactiveComponent extends Component {
get __allFields() {
return _fields;
}
}

const counters: Record<string, number> = {};
counters['id'] = 0;
counters['$type'] = 0;

Object.defineProperty(ReactiveComponent.prototype, 'idCount', {
get() {
return counters['id'];
},
});
Object.defineProperty(ReactiveComponent.prototype, '$typeCount', {
get() {
return counters['$type'];
},
});

Object.defineProperty(ReactiveComponent.prototype, 'id', {
get() {
counters['id']++;
return record['id'] as unknown;
},
});
Object.defineProperty(ReactiveComponent.prototype, '$type', {
get() {
counters['$type']++;
return record['$type'] as unknown;
},
});

fields.forEach((field) => {
counters[field.name] = 0;
Object.defineProperty(ReactiveComponent.prototype, field.name + 'Count', {
get() {
return counters[field.name];
},
});
Object.defineProperty(ReactiveComponent.prototype, field.name, {
get() {
counters[field.name]++;

if (field.kind === 'attribute' || field.kind === 'derived') {
return record[field.name] as unknown;
} else if (field.kind === 'resource') {
return (record[field.name] as ResourceRelationship).data?.id;
}
},
});
});

this.owner.register('component:reactive-component', ReactiveComponent);
this.owner.register(
'template:components/reactive-component',
hbs`<div class="reactive-context"><ul>{{#each this.__allFields as |prop|}}<li>{{prop}}: {{get this prop}}</li>{{/each}}</ul></div>`
);

await render(hbs`<ReactiveComponent />`);

function reset() {
fields.forEach((field) => {
counters[field.name] = 0;
});
}

return { counters, reset, fieldOrder: _fields };
}
Loading

0 comments on commit d9b7fe1

Please sign in to comment.