diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index ff175cc3..e549b693 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -5,8 +5,9 @@ on: push: branches: - master + - check-signals pull_request: - branches: [master] + branches: [master, check-signals] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -15,8 +16,8 @@ concurrency: env: EXPERIMENT_BRANCH_NAME: ${{ github.head_ref || github.ref_name }} CONTROL_BRANCH_NAME: "master" - FIDELITY: 100 - THROTTLE: 4 + FIDELITY: 50 + THROTTLE: 1 FORK_NAME: ${{ github.event.pull_request.head.repo.full_name }} jobs: diff --git a/package.json b/package.json index cf4eb262..8a561d34 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "@types/qunit": "^2.19.9", "autoprefixer": "^10.4.16", "backburner.js": "^2.8.0", + "express": "^4.18.2", + "glint-environment-gxt": "file:./glint-environment-gxt", + "happy-dom": "^13.0.6", "nyc": "^15.1.0", "postcss": "^8.4.33", "prettier": "^3.1.1", @@ -93,16 +96,14 @@ "vite-plugin-circular-dependency": "^0.2.1", "vite-plugin-dts": "^3.7.0", "vitest": "^1.1.1", - "zx": "^7.2.3", - "express": "^4.18.2", - "happy-dom": "^13.0.6", - "glint-environment-gxt": "file:./glint-environment-gxt" + "zx": "^7.2.3" }, "dependencies": { "@babel/core": "^7.23.6", - "decorator-transforms": "1.1.0", "@babel/preset-typescript": "^7.23.3", "@glimmer/syntax": "^0.87.1", - "content-tag": "^1.2.2" + "content-tag": "^1.2.2", + "decorator-transforms": "1.1.0", + "signal-polyfill": "0.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c26729..0a44af7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: decorator-transforms: specifier: 1.1.0 version: 1.1.0(@babel/core@7.23.6) + signal-polyfill: + specifier: 0.1.2 + version: 0.1.2 devDependencies: '@glint/core': @@ -4761,6 +4764,7 @@ packages: /glob@5.0.15: resolution: {integrity: sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==} + deprecated: Glob versions prior to v9 are no longer supported dependencies: inflight: 1.0.6 inherits: 2.0.4 @@ -6787,6 +6791,7 @@ packages: /rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -6987,6 +6992,10 @@ packages: engines: {node: '>=14'} dev: true + /signal-polyfill@0.1.2: + resolution: {integrity: sha512-HT9d+L9NMiTzMxb/tU2Baym6129ROyRETSjvchvSkQa7wN0+SrG/IUlsaBLqKn2c+4mlze6CgQBEvgBjxOpiaQ==} + dev: false + /silent-error@1.1.1: resolution: {integrity: sha512-n4iEKyNcg4v6/jpb3c0/iyH2G1nzUNl7Gpqtn/mHIJK9S/q/7MCfoO4rwVOoO59qPFIc0hVHvMbiOJ0NdtxKKw==} dependencies: diff --git a/src/utils/benchmark.ts b/src/utils/benchmark.ts index 98dd9908..f3afe029 100644 --- a/src/utils/benchmark.ts +++ b/src/utils/benchmark.ts @@ -2,7 +2,7 @@ import { Application } from '@/components/Application.gts'; import { withRehydration } from '@/utils/rehydration'; import { getDocument } from '@/utils/dom-api'; import { measureRender } from '@/utils/measure-render'; -import { setResolveRender } from '@/utils/runtime'; +import { setResolveRender } from '@/utils/signals'; export function createBenchmark() { return { diff --git a/src/utils/dom-api.ts b/src/utils/dom-api.ts index 2e19a5fe..8a9cec3d 100644 --- a/src/utils/dom-api.ts +++ b/src/utils/dom-api.ts @@ -1,7 +1,7 @@ import { getNodeCounter, incrementNodeCounter } from '@/utils/dom'; import { IN_SSR_ENV } from './shared'; -let $doc = +var $doc = typeof document !== 'undefined' ? document : (undefined as unknown as Document); @@ -11,7 +11,7 @@ export function setDocument(newDocument: Document) { export function getDocument() { return $doc; } -export const api = { +export var api = { attr(element: HTMLElement, name: string, value: string | null) { element.setAttribute(name, value === null ? '' : value); }, diff --git a/src/utils/dom.ts b/src/utils/dom.ts index d745de4c..2661636d 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -517,6 +517,7 @@ export function $_inElement( } else if (isTagLike(elementRef)) { appendRef = elementRef.value; } else { + // @ts-expect-error appendRef = elementRef; } const destructors: Destructors = []; diff --git a/src/utils/glimmer-validator.ts b/src/utils/glimmer-validator.ts index ac1f6a02..e1c64b14 100644 --- a/src/utils/glimmer-validator.ts +++ b/src/utils/glimmer-validator.ts @@ -41,6 +41,7 @@ export function trackedData( let hasInitializer = typeof initializer === 'function'; function getter(self: T) { + // @ts-expect-error consumeTag(cellFor(self, key)); let value; diff --git a/src/utils/if.ts b/src/utils/if.ts index 1c7a07af..ab38b0d0 100644 --- a/src/utils/if.ts +++ b/src/utils/if.ts @@ -83,6 +83,7 @@ export function ifCondition( } }, runExistingDestructors, + // @ts-expect-error opcodeFor(cell, (value) => { if (throwedError) { Promise.resolve().then(() => { diff --git a/src/utils/list.ts b/src/utils/list.ts index 6934c41e..04847e8e 100644 --- a/src/utils/list.ts +++ b/src/utils/list.ts @@ -110,6 +110,7 @@ export class BasicListComponent { if (!isTagLike(tag)) { if (isArray(tag)) { console.warn('iterator for @each should be a cell'); + // @ts-expect-error tag = new Cell(tag, 'list tag'); } else if (isFn(originalTag)) { tag = formula(() => deepFnValue(originalTag), 'list tag'); @@ -297,6 +298,7 @@ export class SyncListComponent< constructor(params: ListComponentArgs, outlet: RenderTarget) { super(params, outlet); associateDestroyable(params.ctx, [ + // @ts-expect-error opcodeFor(this.tag, (value) => { this.syncList(value as T[]); }), @@ -331,6 +333,7 @@ export class AsyncListComponent< constructor(params: ListComponentArgs, outlet: RenderTarget) { super(params, outlet); associateDestroyable(params.ctx, [ + // @ts-expect-error opcodeFor(this.tag, async (value) => { await this.syncList(value as T[]); }), diff --git a/src/utils/reactive.ts b/src/utils/reactive.ts index f2a7cebd..13c7e8c8 100644 --- a/src/utils/reactive.ts +++ b/src/utils/reactive.ts @@ -3,8 +3,9 @@ It's related to Glimmer-VM's `@tracked` system, but without invalidation step. We explicitly update DOM only when it's needed and only if tags are changed. */ -import { scheduleRevalidate } from '@/utils/runtime'; import { isFn, isTag, isTagLike, debugContext } from '@/utils/shared'; +import { Signal } from "signal-polyfill"; + export const asyncOpcodes = new WeakSet(); // List of DOM operations for each tag @@ -101,12 +102,9 @@ export function setIsRendering(value: boolean) { _isRendering = value; } -function tracker() { - return new Set(); -} // "data" cell, it's value can be updated, and it's used to create derived cells export class Cell { - _value!: T; + _value!: Signal.State; declare toHTML: () => string; [Symbol.toPrimitive]() { return this.value; @@ -114,25 +112,21 @@ export class Cell { _debugName?: string | undefined; [isTag] = true; constructor(value: T, debugName?: string) { - this._value = value; + this._value = new Signal.State(value); if (IS_DEV_MODE) { this._debugName = debugContext(debugName); + // @ts-expect-error DEBUG_CELLS.add(this); } } get value() { - if (currentTracker !== null) { - currentTracker.add(this); - } - return this._value; + return this._value.get(); } set value(value: T) { this.update(value); } update(value: T) { - this._value = value; - tagsToRevalidate.add(this); - scheduleRevalidate(); + this._value.set(value); } } @@ -162,16 +156,9 @@ export function relatedTagsForCell(cell: Cell) { return relatedTags.get(cell)!; } -function bindAllCellsToTag(cells: Set, tag: MergedCell) { - cells.forEach((cell) => { - const tags = relatedTagsForCell(cell); - tags.add(tag); - }); -} - // "derived" cell, it's value is calculated from other cells, and it's value can't be updated export class MergedCell { - fn: Fn | Function; + fn: Fn | Function; declare toHTML: () => string; isConst: boolean = false; isDestroyed = false; @@ -190,95 +177,18 @@ export class MergedCell { } destroy() { this.isDestroyed = true; - opsForTag.delete(this); - if (this.relatedCells !== null) { - this.relatedCells.forEach((cell) => { - const related = relatedTags.get(cell); - if (related !== undefined) { - related.delete(this); - if (related.size === 0) { - relatedTags.delete(cell); - } - } - }); - this.relatedCells.clear(); - } if (IS_DEV_MODE) { DEBUG_MERGED_CELLS.delete(this); } } get value() { - if (this.isDestroyed) { - return; - } - - if (this.isConst || !_isRendering || currentTracker !== null) { - return this.fn(); - } - - try { - currentTracker = tracker(); - return this.fn(); - } finally { - bindAllCellsToTag(currentTracker!, this); - this.isConst = currentTracker!.size === 0; - this.relatedCells = currentTracker; - currentTracker = null; - } + return this.fn(); } } // this function is called when we need to update DOM, values represented by tags are changed export type tagOp = (...values: unknown[]) => Promise | void; -// this is runtime function, it's called when we need to update DOM for a specific tag -export async function executeTag(tag: Cell | MergedCell) { - let opcode: null | tagOp = null; - // we always have ops for a tag - if (!opsForTag.has(tag)) { - return; - } - const ops = opsFor(tag)!; - if (TRY_CATCH_ERROR_HANDLING) { - try { - const value = tag.value; - for (const op of ops) { - opcode = op; - if (asyncOpcodes.has(op)) { - await op(value); - } else { - op(value); - } - } - } catch (e: any) { - if (IS_DEV_MODE) { - console.error({ - message: 'Error executing tag', - error: e, - tag, - opcode: opcode?.toString(), - }); - } - if (opcode) { - let index = ops.indexOf(opcode); - if (index > -1) { - ops.splice(index, 1); - } - } - } - } else { - const value = tag.value; - for (const op of ops) { - opcode = op; - if (asyncOpcodes.has(op)) { - await op(value); - } else { - op(value); - } - } - } -} - // this is function to create a reactive cell from an object property export function cellFor( obj: T, @@ -292,6 +202,7 @@ export function cellFor( obj[key], `${obj.constructor.name}.${String(key)}`, ); + // @ts-expect-error refs.set(key, cellValue); cellsMap.set(obj, refs); Object.defineProperty(obj, key, { @@ -327,10 +238,10 @@ export function cell(value: T, debugName?: string) { } export function inNewTrackingFrame(callback: () => void) { - const existingTracker = currentTracker; - currentTracker = null; + // const existingTracker = currentTracker; + // currentTracker = null; callback(); - currentTracker = existingTracker; + // currentTracker = existingTracker; } export function getTracker() { diff --git a/src/utils/rehydration.ts b/src/utils/rehydration.ts index 3d89b82b..ea016734 100644 --- a/src/utils/rehydration.ts +++ b/src/utils/rehydration.ts @@ -2,6 +2,8 @@ import { type ComponentReturnType } from '@/utils/component'; import { getNodeCounter, resetNodeCounter } from '@/utils/dom'; import { api as rehydrationDomApi } from '@/utils/rehydration-dom-api'; import { api as domApi } from '@/utils/dom-api'; + +var originalDomAPI = { ...domApi }; const withRehydrationStack: HTMLElement[] = []; const commentsToRehydrate: Comment[] = []; let rehydrationScheduled = false; @@ -109,8 +111,8 @@ function pushToStack(node: HTMLElement, isFirst = false) { } } -const originalDomAPI = { ...domApi }; function patchDOMAPI() { + originalDomAPI = { ...domApi }; domApi.attr = rehydrationDomApi.attr; domApi.comment = rehydrationDomApi.comment; // @ts-expect-error diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts index a29598db..e69de29b 100644 --- a/src/utils/runtime.ts +++ b/src/utils/runtime.ts @@ -1,57 +0,0 @@ -import { - setIsRendering, - type MergedCell, - tagsToRevalidate, - executeTag, - relatedTags, -} from '@/utils/reactive'; -import { isRehydrationScheduled } from './rehydration'; - -let revalidateScheduled = false; -type voidFn = () => void; -let resolveRender: undefined | voidFn = undefined; - -export function setResolveRender(value: () => void) { - resolveRender = value; -} - -export function scheduleRevalidate() { - if (!revalidateScheduled) { - if (IS_DEV_MODE) { - if (isRehydrationScheduled()) { - throw new Error('You can not schedule revalidation during rehydration'); - } - } - revalidateScheduled = true; - Promise.resolve().then(async () => { - await syncDom(); - if (resolveRender !== undefined) { - resolveRender(); - resolveRender = undefined; - } - revalidateScheduled = false; - }); - } -} -export async function syncDom() { - const sharedTags = new Set(); - setIsRendering(true); - for (const cell of tagsToRevalidate) { - await executeTag(cell); - // we always have related tags - if (relatedTags.has(cell)) { - const subTags = relatedTags.get(cell)!; - relatedTags.delete(cell); - subTags.forEach((tag) => { - sharedTags.add(tag); - }); - subTags.clear(); - } - } - tagsToRevalidate.clear(); - for (const tag of sharedTags) { - await executeTag(tag); - } - sharedTags.clear(); - setIsRendering(false); -} diff --git a/src/utils/signals.ts b/src/utils/signals.ts new file mode 100644 index 00000000..013a14da --- /dev/null +++ b/src/utils/signals.ts @@ -0,0 +1,42 @@ +import { Signal } from "signal-polyfill"; +import { isRehydrationScheduled } from "./rehydration"; +import { setIsRendering } from "./reactive"; + +let revalidateScheduled = false; +type voidFn = () => void; +let resolveRender: undefined | voidFn = undefined; +export const w = new Signal.subtle.Watcher(() => { + scheduleRevalidate(); +}); + +export function setResolveRender(value: () => void) { + resolveRender = value; +} + +export function scheduleRevalidate() { + if (!revalidateScheduled) { + if (IS_DEV_MODE) { + if (isRehydrationScheduled()) { + throw new Error('You can not schedule revalidation during rehydration'); + } + } + revalidateScheduled = true; + Promise.resolve().then(async () => { + await syncDom(); + if (resolveRender !== undefined) { + resolveRender(); + resolveRender = undefined; + } + revalidateScheduled = false; + }); + } +} +export async function syncDom() { + setIsRendering(true); + w.getPending().forEach((cell) => { + cell.get(); + }); + w.watch(); + setIsRendering(false); +} + diff --git a/src/utils/vm.ts b/src/utils/vm.ts index 3e9cda8f..a5d391ce 100644 --- a/src/utils/vm.ts +++ b/src/utils/vm.ts @@ -1,16 +1,16 @@ import { - opsForTag, type AnyCell, type tagOp, asyncOpcodes, setIsRendering, isRendering, formula, - opsFor, inNewTrackingFrame, } from './reactive'; import { isFn } from './shared'; +import { Signal } from "signal-polyfill"; +import { w } from './signals'; type maybeDestructor = undefined | (() => void); type maybePromise = undefined | Promise; @@ -92,20 +92,12 @@ export function evaluateOpcode(tag: AnyCell, op: tagOp) { } export function opcodeFor(tag: AnyCell, op: tagOp) { - evaluateOpcode(tag, op); - const ops = opsFor(tag)!; - ops.push(op); + const computed = new Signal.Computed(() => { + op(tag.value); + }); + w.watch(computed); + computed.get(); return () => { - // console.info(`Removing Updating Opcode for ${tag._debugName}`, tag); - const index = ops.indexOf(op); - if (index > -1) { - ops.splice(index, 1); - } - if (ops.length === 0) { - opsForTag.delete(tag); - if ('destroy' in tag) { - tag.destroy(); - } - } + w.unwatch(computed); }; }