diff --git a/.changeset/smooth-papayas-think.md b/.changeset/smooth-papayas-think.md new file mode 100644 index 0000000..5a7ec4d --- /dev/null +++ b/.changeset/smooth-papayas-think.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/d2ts': patch +--- + +Use a polyfill FinalizationRegistry when the native one isn't available to enable React Native support diff --git a/eslint.base.mjs b/eslint.base.mjs index d25409a..4c4b6ae 100644 --- a/eslint.base.mjs +++ b/eslint.base.mjs @@ -47,14 +47,31 @@ export default [ ecmaVersion: 2020, sourceType: 'module', globals: { - node: true, + // Node.js globals + process: true, + __dirname: true, + __filename: true, + exports: true, + module: true, + require: true, + Buffer: true, console: true, + global: true, + globalThis: true, + // Browser globals window: true, document: true, - globalThis: true, EventTarget: true, CustomEvent: true, - EventListener: true + EventListener: true, + // Node.js types + NodeJS: true, + setTimeout: true, + clearTimeout: true, + setInterval: true, + clearInterval: true, + setImmediate: true, + clearImmediate: true }, }, ignores: ['dist/', 'node_modules/'], diff --git a/packages/d2ts/src/utils.ts b/packages/d2ts/src/utils.ts index 093e396..dbef35e 100644 --- a/packages/d2ts/src/utils.ts +++ b/packages/d2ts/src/utils.ts @@ -6,7 +6,7 @@ import murmurhash from 'murmurhash-js' */ export class WeakRefMap { private cacheMap = new Map>() - private finalizer = new FinalizationRegistry((key: K) => { + private finalizer = new AnyFinalizationRegistry((key: K) => { this.cacheMap.delete(key) }) @@ -115,3 +115,69 @@ export function hash(data: any): string | number { hashCache.set(data, hashValue) return hashValue } + +/** + * This is a mock implementation of FinalizationRegistry which uses WeakRef to + * track the target objects. It's used in environments where FinalizationRegistry + * is not available but WeakRef is (e.g. React Native >=0.75 on New Architecture). + * Based on https://gist.github.com/cray0000/abecb1ca71fd28a1d8efff2be9e0f6c5 + * MIT License - Copyright Cray0000 + */ +export class WeakRefBasedFinalizationRegistry { + private counter = 0 + private registrations = new Map() + private sweepTimeout: NodeJS.Timeout | undefined + private finalize: (value: any) => void + private sweepIntervalMs = 10_000 + + constructor(finalize: (value: any) => void, sweepIntervalMs?: number) { + this.finalize = finalize + if (sweepIntervalMs !== undefined) { + this.sweepIntervalMs = sweepIntervalMs + } + } + + register(target: any, value: any, token: any) { + this.registrations.set(this.counter, { + targetRef: new WeakRef(target), + tokenRef: token != null ? new WeakRef(token) : undefined, + value, + }) + this.counter++ + this.scheduleSweep() + } + + unregister(token: any) { + if (token == null) return + this.registrations.forEach((registration, key) => { + if (registration.tokenRef?.deref() === token) { + this.registrations.delete(key) + } + }) + } + + // Bound so it can be used directly as setTimeout callback. + private sweep = () => { + clearTimeout(this.sweepTimeout) + this.sweepTimeout = undefined + + this.registrations.forEach((registration, key) => { + if (registration.targetRef.deref() !== undefined) return + const value = registration.value + this.registrations.delete(key) + this.finalize(value) + }) + + if (this.registrations.size > 0) this.scheduleSweep() + } + + private scheduleSweep() { + if (this.sweepTimeout) return + this.sweepTimeout = setTimeout(this.sweep, this.sweepIntervalMs) + } +} + +const AnyFinalizationRegistry = + typeof FinalizationRegistry !== 'undefined' + ? FinalizationRegistry + : WeakRefBasedFinalizationRegistry diff --git a/packages/d2ts/tests/utils.test.ts b/packages/d2ts/tests/utils.test.ts index ee563fb..7baa293 100644 --- a/packages/d2ts/tests/utils.test.ts +++ b/packages/d2ts/tests/utils.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { DefaultMap, WeakRefMap, hash } from '../src/utils.js' +import { describe, it, expect, vi } from 'vitest' +import { DefaultMap, WeakRefMap, hash, WeakRefBasedFinalizationRegistry } from '../src/utils.js' describe('DefaultMap', () => { it('should return default value for missing keys', () => { @@ -394,3 +394,81 @@ describe('hash', () => { }) }) }) + +describe('WeakRefBasedFinalizationRegistry', () => { + it('should register and unregister objects', () => { + const finalizeSpy = vi.fn() + const registry = new WeakRefBasedFinalizationRegistry(finalizeSpy) + + const target = { test: 'value' } + const token = { token: 'value' } + const value = 'test value' + + registry.register(target, value, token) + registry.unregister(token) + + // The finalize callback should not have been called since we unregistered + expect(finalizeSpy).not.toHaveBeenCalled() + }) + + // TODO: find a way to make this actually work... + // it('should call finalize when target is garbage collected', async () => { + // const finalizeSpy = vi.fn() + // const registry = new WeakRefBasedFinalizationRegistry(finalizeSpy, 100) // Use 100ms interval for testing + + // // Create object in a scope that will end + // { + // const target = { test: 'value' } + // const value = 'test value' + // registry.register(target, value, target) + // } + + // // Force garbage collection if possible + // if (global.gc) { + // // Run GC multiple times to ensure cleanup + // for (let i = 0; i < 3; i++) { + // global.gc() + // // Give finalizers a chance to run + // await new Promise(resolve => setTimeout(resolve, 0)) + // } + + // // Wait for the sweep interval (plus a small buffer) + // await new Promise(resolve => setTimeout(resolve, 200)) + + // // The finalize callback should have been called + // expect(finalizeSpy).toHaveBeenCalledWith('test value') + // } else { + // console.warn('Test skipped: garbage collection not exposed. Run Node.js with --expose-gc flag.') + // } + // }) + + it('should handle multiple registrations', () => { + const finalizeSpy = vi.fn() + const registry = new WeakRefBasedFinalizationRegistry(finalizeSpy) + + const target1 = { test: 'value1' } + const target2 = { test: 'value2' } + const token1 = { token: 'value1' } + const token2 = { token: 'value2' } + + registry.register(target1, 'value1', token1) + registry.register(target2, 'value2', token2) + + // Unregister one token + registry.unregister(token1) + + // The finalize callback should not have been called + expect(finalizeSpy).not.toHaveBeenCalled() + }) + + it('should handle null token in unregister', () => { + const finalizeSpy = vi.fn() + const registry = new WeakRefBasedFinalizationRegistry(finalizeSpy) + + const target = { test: 'value' } + registry.register(target, 'value', null) + + // Should not throw when unregistering with null token + expect(() => registry.unregister(null)).not.toThrow() + }) +}) diff --git a/packages/d2ts/vitest.config.js b/packages/d2ts/vitest.config.js new file mode 100644 index 0000000..999157b --- /dev/null +++ b/packages/d2ts/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + poolOptions: { + forks: { + execArgv: ['--expose-gc'], + }, + }, + }, +});