Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-papayas-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-sql/d2ts': patch
---

Use a polyfill FinalizationRegistry when the native one isn't available to enable React Native support
23 changes: 20 additions & 3 deletions eslint.base.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/'],
Expand Down
68 changes: 67 additions & 1 deletion packages/d2ts/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import murmurhash from 'murmurhash-js'
*/
export class WeakRefMap<K, V extends object> {
private cacheMap = new Map<K, WeakRef<V>>()
private finalizer = new FinalizationRegistry((key: K) => {
private finalizer = new AnyFinalizationRegistry((key: K) => {
this.cacheMap.delete(key)
})

Expand Down Expand Up @@ -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
82 changes: 80 additions & 2 deletions packages/d2ts/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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()
})
})
11 changes: 11 additions & 0 deletions packages/d2ts/vitest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
poolOptions: {
forks: {
execArgv: ['--expose-gc'],
},
},
},
});