Skip to content

Instantly share code, notes, and snippets.

@jer-k
Last active January 14, 2025 22:24
Show Gist options
  • Save jer-k/65a590227e5288d98ee7292b360db212 to your computer and use it in GitHub Desktop.
Save jer-k/65a590227e5288d98ee7292b360db212 to your computer and use it in GitHub Desktop.
Chatting about Zero with Claude
I'm trying to debug an issue with this project and I've never seen this syntax before `#views = new Map<string, ViewWrapper<any, any>>();`. What is the `#views` signifying in this typescript file?
The `#` symbol in `#views` is JavaScript's private class fields syntax, introduced in JavaScript/ECMAScript 2022. It's not TypeScript-specific, but TypeScript supports it.
When you prefix a class field or method with `#`, it makes that member strictly private and only accessible within the class itself. This is different from TypeScript's `private` modifier in a few important ways:
1. It's actually enforced at runtime (TypeScript's `private` is only enforced at compile time)
2. It's truly private - there's no way to access it from outside the class, even through reflection
3. The privacy is implemented through a special JavaScript runtime mechanism
Here's a quick example to illustrate:
```typescript
class Example {
#privateField = 1; // True private field, enforced at runtime
private typeScriptPrivate = 2; // TypeScript private, only compile-time checking
method() {
console.log(this.#privateField); // OK
// Only accessible inside the class
}
}
const e = new Example();
e.#privateField; // Syntax Error: Cannot access private field
```
In your specific code, `#views` is a private Map that stores view wrappers, and it can only be accessed by methods within the `ViewStore` class. This provides true encapsulation and prevents any external code from directly manipulating the views map.
This is particularly useful for maintaining internal state that you want to ensure can't be accessed or modified from outside the class.
In the `ViewWrapper` class I see `getSnapshot = () => this.#snapshot;` this is describing a function, `getSnapshot` which should return the private snapshot information correct?
Yes, exactly! The `getSnapshot = () => this.#snapshot;` is an arrow function that returns the private `#snapshot` field of the `ViewWrapper` class.
This is a way to provide controlled access to the private `#snapshot` data. While the `#snapshot` field itself is private and can't be accessed directly from outside the class, the public `getSnapshot` method provides a way to read its value.
The syntax used here is a class field that's initialized with an arrow function, which is equivalent to defining it in the constructor like this:
```typescript
class ViewWrapper {
#snapshot: QueryResult<TReturn>;
constructor() {
// ... other constructor code ...
this.getSnapshot = () => this.#snapshot;
}
}
```
This pattern is particularly important in this code because `getSnapshot` is used as part of the `useSyncExternalStore` React hook interface, which needs a way to read the current state of the store. You can see this being used in the `useQuery` function:
```typescript
return useSyncExternalStore(
view.subscribeReactInternals,
view.getSnapshot, // Here's where getSnapshot is used
view.getSnapshot
);
```
The hook uses `getSnapshot` to get the current value of the store and to determine when the store's value has changed and the component needs to re-render.
I want to log the return of `view.getSnapshot` right before `useSyncExternalStore`. How would I do that?
You can simply add a console.log statement before the `useSyncExternalStore` call in the `useQuery` function. Since `getSnapshot` is a function, you'll need to call it to see its return value:
```typescript
export function useQuery<
TSchema extends TableSchema,
TReturn extends QueryType,
>(q: Query<TSchema, TReturn>, enable: boolean = true): QueryResult<TReturn> {
const z = useZero();
const view = viewStore.getView(
z.clientID,
q as AdvancedQuery<TSchema, TReturn>,
enable && z.server !== null,
);
// Add this line to log the snapshot
console.log("Current snapshot:", view.getSnapshot());
console.log("About to call useSyncExternalStore and return from useQuery.")
return useSyncExternalStore(
view.subscribeReactInternals,
view.getSnapshot,
view.getSnapshot,
);
}
```
This will show you the current value of the snapshot before it's used in `useSyncExternalStore`. Remember that since this is inside a React hook, you might see this log multiple times during component renders, especially in development mode with React Strict Mode enabled.
Thank you, got that all working. I'm curious if you are able to understand how this file is working with `useSyncExternalStore`. It looks like the `#snapshot` data gets updated in the `#onData` function?
Yes! Let me break down how this system works:
1. The `ViewWrapper` class manages a subscription pattern where:
- `#snapshot` holds the current data
- `#onData` is the callback that updates this data and notifies React about changes
- `subscribeReactInternals` handles React's subscription to these updates
Here's the flow:
```typescript
// When data changes, #onData is called
#onData = (snap: Immutable<Smash<TReturn>>, resultType: ResultType) => {
// Deep clone the new data to ensure immutability
const data = snap === undefined
? snap
: (deepClone(snap as ReadonlyJSONValue) as Smash<TReturn>);
// Update the snapshot with new data and type
this.#snapshot = [data, {type: resultType}] as QueryResult<TReturn>;
// Notify all React subscribers that data has changed
for (const internals of this.#reactInternals) {
internals();
}
};
```
`useSyncExternalStore` works with this system by:
1. Using `subscribeReactInternals` to subscribe to changes
2. Using `getSnapshot` to get the current value
3. Re-rendering the component when the subscription notifies of changes
The flow when used in a React component is:
```typescript
const result = useQuery(query);
// Under the hood:
// 1. Creates/gets a ViewWrapper
// 2. Subscribes to changes via useSyncExternalStore
// 3. When data changes, #onData is called, updating #snapshot
// 4. This triggers React to re-render with the new data
```
This pattern ensures that React components stay synchronized with external data sources in a performant and predictable way.
And what calls `#onData`?
In this code, `#onData` is set up as a listener on `#view` (which is a `TypedView`) when the view is materialized in the `#materializeIfNeeded` method:
```typescript
#materializeIfNeeded = () => {
if (this.#view) {
return;
}
this.#view = this.#query.materialize();
// Here's where #onData is registered as a listener
this.#view.addListener(this.#onData);
this.#onMaterialized(this);
};
```
The actual call to `#onData` would happen within the `TypedView` implementation (which appears to be in another file) whenever the underlying data changes. This could be triggered by:
- Database updates
- Network responses
- Query results changing
- Other data source changes
Since this is part of what looks like a data querying system (given names like `useQuery`, `Query`, `TypedView`), `#onData` would likely be called whenever new query results arrive or when the data being queried gets updated.
To find exactly where `#onData` is called, you'd need to look at the implementation of `TypedView` and how it handles data updates. Would you like me to help you track down that implementation?
```ts mono/packages/zql/src/query/typed-view.ts
import type {Immutable} from '../../../shared/src/immutable.js';
export type ResultType = 'unknown' | 'complete';
/**
* Called when the view changes. The received data should be considered
* immutable. Caller must not modify it. Passed data is valid until next
* time listener is called.
*/
export type Listener<T> = (data: Immutable<T>, resultType: ResultType) => void;
export type TypedView<T> = {
addListener(listener: Listener<T>): () => void;
destroy(): void;
readonly data: T;
};
```
I added the `typed-view.ts` file. Can you tell me a bit more about how the `#onData` is working now?
Ah, this helps! The `TypedView` interface shows us that it's a simple event emitter pattern. Let's connect the dots:
```typescript
// TypedView interface defines:
export type TypedView<T> = {
// addListener takes a callback function (Listener) and returns a cleanup function
addListener(listener: Listener<T>): () => void;
destroy(): void;
readonly data: T;
};
// The Listener type shows what data the callback receives:
export type Listener<T> = (data: Immutable<T>, resultType: ResultType) => void;
```
When we connect this back to our `ViewWrapper`, we can see that `#onData` is implemented to match the `Listener` type:
```typescript
#onData = (snap: Immutable<Smash<TReturn>>, resultType: ResultType) => {
// ... handles the data and notifies React ...
};
// And it gets registered as a listener:
#materializeIfNeeded = () => {
if (this.#view) {
return;
}
this.#view = this.#query.materialize();
this.#view.addListener(this.#onData); // Here's the registration
// ...
};
```
So whenever the underlying `TypedView` implementation calls its listeners (which would happen when query results change), it will:
1. Call `#onData` with the new immutable data and result type
2. Which updates the `#snapshot`
3. Which then notifies React via the stored react internals callbacks
The actual triggering of these updates would be in whatever implements the `TypedView` interface. Would you like to look at that implementation to see exactly when the listeners are called?
I added a few more files related to the `#query` portion. It looks like the query gets materialized and that is what actually fetches the data that gets passed into `#onData` I think? Could you help me figure out where we're actually getting data, and specifically where we pass in the different `resultType`'s (`unknown` and `complete`)?
Did you see my last message about adding more files related to the `#query` portion?
Yes, I see them now! Looking through these files, particularly `query-impl.ts`, we can see that the data flow happens through the `materialize()` method which creates a view using `arrayViewFactory`. This factory creates an `ArrayView` instance which would be responsible for managing the data and calling listeners.
However, I don't see the `ArrayView` implementation in the files provided. That would be the key piece to understanding exactly where the data is coming from and how the `resultType` ('unknown' | 'complete') is determined. Would you be able to share the `ArrayView` implementation? It should be in something like `mono/packages/zql/src/ivm/array-view.ts`?
From what we can see so far:
1. `useQuery` creates/gets a `ViewWrapper`
2. `ViewWrapper` materializes the query which creates an `ArrayView`
3. `ArrayView` would be responsible for managing the data and calling the listeners (including our `#onData`) with the data and `resultType`
But to see exactly how and when the `resultType` changes between 'unknown' and 'complete', we'd need to see the `ArrayView` implementation.
I added the `array-view.ts` file
! Was this what you were looking for?
I added the `array-view.ts` file, can you see it?
Yes, I can see it now! This is exactly what I was looking for. Now we can see the complete flow of how the `resultType` is managed:
1. In `ArrayView`, the `resultType` is managed by the `#complete` private field
2. When `ArrayView` is constructed, it can be marked as complete immediately or wait for a Promise:
```typescript
constructor(
input: Input,
format: Format = {singular: false, relationships: {}},
queryComplete: true | Promise<true> = true,
) {
// ...
if (queryComplete === true) {
this.#complete = true;
} else {
void queryComplete.then(() => {
this.#complete = true;
this.#fireListeners();
});
}
// ...
}
```
3. The `resultType` is passed to listeners (including our `#onData`) in `#fireListener`:
```typescript
#fireListener(listener: Listener<V>) {
listener(
this.data as Immutable<V>,
this.#complete ? 'complete' : 'unknown' // Here's where resultType is determined
);
}
```
So the flow is:
1. Query is materialized, creating an `ArrayView`
2. `ArrayView` manages the data and its completeness state
3. When data changes (via `push()` and `flush()`), listeners are notified
4. Each listener (including our `ViewWrapper`'s `#onData`) gets called with both the current data and whether the query is 'complete' or 'unknown'
The `resultType` effectively tells us whether we have all the data for a query ('complete') or if we're still potentially waiting for more data ('unknown').
Perfect. I have a table `messages` that has 108 rows in it. I have a query that will return all of those messages. This is the query
```ts
const query = z.query.message
.whereExists("sender", (q) => q.where("public_id", "dx5jj6lz9cbw"))
.related("sender", (q) => q.one())
.orderBy("id", "desc");
```
In my application I am doing
```ts
const [messages, result] = useQuery(query);
console.log("messages count", messages.length, result);
```
And what is the following log messages
```
messages count 0 {type: 'unknown'}
messages count 108 {type: 'complete'}
messages count 0 {type: 'complete'}
messages count 108 {type: 'complete'}
```
I'm trying to figure out why it is returning `messages count 0 {type: 'complete'}` after we have already returned a `messages count 108 {type: 'complete'}`. Any ideas of where to investigate for this bug?
This pattern of logs suggests a few potential issues, but the most likely cause is React's strict mode which intentionally double-renders components to help catch certain types of bugs.
In strict mode (<React.StrictMode>), React will:
1. Mount the component
2. Unmount it
3. Mount it again
This would explain why you're seeing the pattern:
1. `0, unknown` (initial state)
2. `108, complete` (data loaded)
3. `0, complete` (unmount/remount in strict mode)
4. `108, complete` (data loaded again)
To investigate this, you could:
1. Check if you're in strict mode by looking at your root React component
2. Add more detailed logging in the ViewWrapper class, particularly in:
```typescript
subscribeReactInternals = (internals: () => void): (() => void) => {
this.#reactInternals.add(internals);
this.#materializeIfNeeded();
return () => {
this.#reactInternals.delete(internals);
if (this.#reactInternals.size === 0) {
this.#view?.destroy();
this.#view = undefined;
this.#onDematerialized();
}
};
};
```
3. Add logging in the `#onData` callback:
```typescript
#onData = (snap: Immutable<Smash<TReturn>>, resultType: ResultType) => {
console.log("onData called with:", snap, resultType);
const data = snap === undefined
? snap
: (deepClone(snap as ReadonlyJSONValue) as Smash<TReturn>);
this.#snapshot = [data, {type: resultType}] as QueryResult<TReturn>;
for (const internals of this.#reactInternals) {
internals();
}
};
```
Are you running in strict mode? That would be the first thing to confirm.
Yes, I'm using Next.js which I believe has strict mode on by default
Ah yes, that explains it! Next.js has strict mode enabled by default in development, which causes this double-mount behavior.
The sequence we're seeing is completely expected in strict mode:
1. `0, unknown` - Initial mount, no data yet
2. `108, complete` - Data loaded
3. `0, complete` - Strict mode unmount/remount (resets to empty but knows query is complete)
4. `108, complete` - Data loaded again after remount
If this is causing issues in your application, you could:
1. Handle the loading state by checking both the messages length and the type:
```typescript
const isLoading = messages.length === 0 && result.type === 'unknown';
```
2. Disable strict mode (not recommended) in your Next.js config:
```javascript
// next.config.js
module.exports = {
reactStrictMode: false,
}
```
3. Use React's `useEffect` to only respond to the data when it's both complete AND has content:
```typescript
useEffect(() => {
if (result.type === 'complete' && messages.length > 0) {
// Handle the actual loaded state
}
}, [messages.length, result.type]);
```
The behavior you're seeing is intentional in development to help catch effects that need cleanup, but won't happen in production. Are you seeing any actual issues in your application because of this, or were you just concerned about the logging pattern?
I am seeing a problem where my UI flickers. It renders the table when the first results come in and then goes back to nothing when the count goes back to 0, then re-renders the results again.
I wanted to investigate the strict mode stuff so I created a production build of my Next.js project via `npm run build` and then ran the project with `npm run start`. Doing so resolves the issue and I don't see the flicker anymore. That is good.
I guess the question would be, is there is any way to better handle
> 3. `0, complete` - Strict mode unmount/remount (resets to empty but knows query is complete)
It feels like if this returned `0, unknown` we could ensure that we don't render anything in the UI until `'complete'` is returned
Yes, that's a good observation! The issue is that when the view is remounted in strict mode, it's returning an empty array with `type: 'complete'` before it has actually re-fetched the data.
Looking at the `ViewStore` code, when a view is disabled (or in this case, being remounted), it returns:
```typescript
if (!enabled) {
return {
getSnapshot: () => getDefaultSnapshot(query.format.singular),
subscribeReactInternals: disabledSubscriber,
};
}
```
And `getDefaultSnapshot` returns:
```typescript
const defaultSnapshots = {
singular: [undefined, {type: 'unknown'}] as const,
plural: [emptyArray, {type: 'unknown'}] as const,
};
```
We could modify this to ensure that any empty state during remount is marked as 'unknown'. One way to do this would be to modify the `ArrayView` constructor to always start with `#complete = false`:
```typescript
constructor(
input: Input,
format: Format = {singular: false, relationships: {}},
queryComplete: true | Promise<true> = true,
) {
this.#input = input;
this.#schema = input.getSchema();
this.#format = format;
this.#root = {'': format.singular ? undefined : []};
input.setOutput(this);
this.#complete = false; // Always start as incomplete
if (queryComplete === true) {
// Mark as complete after initial hydration
queueMicrotask(() => {
this.#complete = true;
this.#fireListeners();
});
} else {
void queryComplete.then(() => {
this.#complete = true;
this.#fireListeners();
});
}
this.#hydrate();
}
```
This would ensure that any fresh mount of the view (including during strict mode remounting) would start with `type: 'unknown'` until the data is actually loaded.
Would you like to try implementing this change and see if it helps with the development experience?
Can you explain the `queueMicrotask` portion?
`queueMicrotask` is a web API that lets you schedule a task to run after the current synchronous code completes, but before the next macro task (like setTimeout or requestAnimationFrame). It's similar to using `Promise.resolve().then()` but is more explicit in its intention.
In this case, we're using it to ensure that the view starts as 'unknown' and only becomes 'complete' after the initial hydration has finished. Here's the sequence:
```typescript
constructor(...) {
// ... setup code ...
this.#complete = false; // 1. Start as incomplete
if (queryComplete === true) {
queueMicrotask(() => {
// 3. This runs after constructor and #hydrate finish
this.#complete = true;
this.#fireListeners();
});
}
this.#hydrate(); // 2. Hydrate the view with data
}
```
Without `queueMicrotask`, if `queryComplete` is `true`, we would set `#complete = true` immediately, before `#hydrate()` has populated the data. This could lead to firing listeners with an empty dataset marked as 'complete'.
With `queueMicrotask`, we ensure that:
1. The view starts as 'unknown'
2. The hydration happens
3. Then in the next microtask, we mark it as 'complete' and notify listeners
This helps maintain the invariant that a 'complete' status always comes with actual data.
```ts
get data() {
return this.#root[''] as V;
}
```
Can you tell me more about this function? Why does it have a `get` in front of it?
The `get` keyword in JavaScript/TypeScript defines a getter property - it's a way to make a function behave like a property. When you access `arrayView.data`, it calls this function to retrieve the value, but you don't need to use parentheses like you would with a regular method call.
Here's a simple example to illustrate:
```typescript
class Example {
#value = 42;
// Using a getter
get data() {
return this#value;
}
// Regular method
getData() {
return this.#value;
}
}
const ex = new Example();
console.log(ex.data); // Access like a property: 42
console.log(ex.getData()); // Call like a method: 42
```
In your `ArrayView` class, `data` is accessing the root's empty string key (`#root['']`) and casting it to type `V`. The getter syntax makes it feel more natural when accessing the view's data:
```typescript
const arrayView = new ArrayView(...);
console.log(arrayView.data); // Access the data directly
```
Instead of:
```typescript
console.log(arrayView.getData()); // Would feel less natural
```
This is particularly useful for the `TypedView` interface which expects a `data` property, not a method. The getter allows the implementation to compute or retrieve the value while still conforming to the interface that expects a property.
Okay. I'm still trying to debug a problem and I added some logging to `#fireListener` so that it looks like this
```ts
#fireListener(listener: Listener<V>) {
console.log("Inside #fireListener, what is this?", this)
console.log("What is this.#complete?", this.#complete)
console.log("What is this.data?", this.data)
console.log("What is this.#root?", this.#root)
console.log("What is this after calling this.data and this.#root?", this)
listener(
this.data as Immutable<V>,
this.#complete ? 'complete' : 'unknown',
);
}
```
Now when this logs in Chrome, I can visually see that in the first log of `this`, `this.#root` is `{"": Array(108)}`. The next log, logs `this.#complete` as `true`. Then it logs `this.data` as `[]`, an empty array. Next it logs `this.#root` as `{"": Array(0)}`. Finally I relog `this` and I can visually see `this.#root` is `{"": Array(108)}` again. Do you have any idea why the logs for `this.data` and `this.#root` would show different data than what I can see when just logging `this`?
Ah, this is a classic Chrome DevTools console logging behavior! When you log an object directly (like `this`), Chrome shows you a "live" view of the object that you can expand and inspect at the time you click on it in the console, not at the time it was logged.
However, when you log specific properties (like `this.data` or `this.#root`), it captures and logs the value at that exact moment.
So what's likely happening is:
1. `console.log("Inside #fireListener, what is this?", this)` - Logs the object, but shows the final state when you expand it
2. `console.log("What is this.#complete?", this.#complete)` - Shows true
3. `console.log("What is this.data?", this.data)` - Shows the actual empty array at this moment
4. `console.log("What is this.#root?", this.#root)` - Shows the empty root at this moment
5. `console.log("What is this after...?", this)` - Logs the object, but shows the final state when you expand it
To get a more accurate picture of the object at each moment, you can use:
```typescript
#fireListener(listener: Listener<V>) {
console.log("Inside #fireListener, what is this?", JSON.parse(JSON.stringify(this.#root)));
// ... rest of logs
}
```
Or use `console.dir(this, { depth: null })` which will capture a snapshot of the object at that moment.
This behavior in Chrome's console can be confusing when debugging, as the "live" view of objects can make it appear like values are changing mysteriously
I used ` console.dir(this, { depth: null })` but I'm still seeing `this.#root` having data. Should I try something different?
Did you see my last message?
Yes, I see that `console.dir(this, { depth: null })` didn't help resolve the issue.
Let's try a different approach. We could try to capture the exact state at each point by creating immutable copies:
```typescript
#fireListener(listener: Listener<V>) {
const rootCopy = JSON.parse(JSON.stringify(this.#root));
console.log("Root copy at start:", rootCopy);
const dataCopy = JSON.parse(JSON.stringify(this.data));
console.log("Data copy:", dataCopy);
const rootCopy2 = JSON.parse(JSON.stringify(this.#root));
console.log("Root copy after accessing data:", rootCopy2);
listener(
this.data as Immutable<V>,
this.#complete ? 'complete' : 'unknown',
);
}
```
This should give us a clearer picture of what's happening at each step. Are you able to try this modification? It would help us understand if something is actually modifying the data when we access it through the getter.
I want to capture the entire object though, not just `this.#root` so I can try to compare it to other places i'm logging. How could I copy the whole thing?
Ah, to capture the entire object including its private fields, we could use a combination of `structuredClone` (which is more robust than JSON.stringify/parse) and a debug object that exposes the private fields we care about:
```typescript
#fireListener(listener: Listener<V>) {
const debug = {
root: this.#root,
complete: this.#complete,
dirty: this.#dirty,
schema: this.#schema,
format: this.#format,
listeners: this.#listeners.size,
// any other private fields you want to capture
};
console.log("Start of #fireListener, full state:", structuredClone(debug));
console.log("After accessing data:", structuredClone({
...debug,
data: this.data
}));
listener(
this.data as Immutable<V>,
this.#complete ? 'complete' : 'unknown',
);
}
```
This will give us a complete snapshot of the object's state at each point, including all the private fields we want to track. The `structuredClone` will ensure we get a true copy of the state at that moment rather than a live reference.
Does Javascript have some form of like a global object id method for each object so it can be uniquely identified?
Yes! In modern JavaScript, you can use `WeakMap` to create a unique identifier system for objects. Here's a simple implementation:
```typescript
const objectIds = new WeakMap();
let nextId = 0;
function getObjectId(obj: object): number {
let id = objectIds.get(obj);
if (id === undefined) {
id = nextId++;
objectIds.set(obj, id);
}
return id;
}
```
Then you could use it in your logging:
```typescript
#fireListener(listener: Listener<V>) {
console.log("Object ID:", getObjectId(this));
console.log("Root Object ID:", getObjectId(this.#root));
// ... rest of your logging
}
```
This way you can track if you're dealing with the same object instance across different points in your code. Each object will get a unique ID that persists as long as the object exists, and the WeakMap won't prevent garbage collection.
Would you like to try implementing this to help track what's happening with your objects?
In the `query-impl.ts` file, there is the `arrayViewFactory` function. It is called in the `materialize` function. I was wondering if you could explain to me this part
```
cb => {
removeCommitObserver = this.#delegate.onTransactionCommit(cb);
},
```
This part is setting up a callback system for when transactions commit. Let's break it down:
```typescript
// In query-impl.ts
function arrayViewFactory<TSchema extends TableSchema, TReturn extends QueryType>(
_query: Query<TSchema, TReturn>,
input: Input,
format: Format,
onDestroy: () => void,
onTransactionCommit: (cb: () => void) => void, // This is the parameter we're looking at
queryComplete: true | Promise<true>,
): TypedView<Smash<TReturn>> {
const v = new ArrayView<Smash<TReturn>>(input, format, queryComplete);
v.onDestroy = onDestroy;
onTransactionCommit(() => { // Here we pass a callback that
v.flush(); // calls flush() on the view
});
return v;
}
```
When called in `materialize`:
```typescript
const view = this.#delegate.batchViewUpdates(() =>
(factory ?? arrayViewFactory)(
this,
input,
this.format,
onDestroy,
cb => { // This is the part you asked about
removeCommitObserver = this.#delegate.onTransactionCommit(cb);
},
queryGot || queryCompleteResolver.promise,
)
);
```
This is setting up a system where:
1. The ArrayView can be notified when transactions commit
2. When a transaction commits, the view's `flush()` method is called
3. The `removeCommitObserver` variable holds a cleanup function to remove the observer when needed
It's essentially a way to ensure the view updates its state and notifies its listeners whenever a transaction completes. This is important for keeping the view's data in sync with any database changes.
The pattern is:
1. ArrayView says "I want to know when transactions commit"
2. The system says "Ok, give me a callback to run when that happens"
3. ArrayView provides `flush()` as that callback
4. The system sets up the observer and returns a way to clean it up later
Would you like me to explain more about what `flush()` does or how the transaction commit system works?
What is the `cb => {}` syntax? I don't understand what `cb` is?
`cb =>` is shorthand for defining a function that takes a parameter named `cb` (short for "callback"). It's an arrow function syntax.
Let's rewrite it in a more verbose way to make it clearer:
```typescript
// This:
cb => {
removeCommitObserver = this.#delegate.onTransactionCommit(cb);
}
// Is the same as this:
function(cb) {
removeCommitObserver = this.#delegate.onTransactionCommit(cb);
}
```
And in the context of `arrayViewFactory`, this function is passed as the `onTransactionCommit` parameter:
```typescript
function arrayViewFactory(
_query: Query<TSchema, TReturn>,
input: Input,
format: Format,
onDestroy: () => void,
onTransactionCommit: (cb: () => void) => void, // This parameter expects a function that takes a callback
queryComplete: true | Promise<true>,
) {
// ...
onTransactionCommit(() => { // Here we pass a callback function
v.flush(); // that will call v.flush()
});
// ...
}
```
So when the factory calls `onTransactionCommit(() => { v.flush(); })`, that arrow function becomes the `cb` parameter in the original snippet, which then gets passed to `this.#delegate.onTransactionCommit(cb)`.
Does that help explain the syntax better?
It does, thank you. So the `this.#delegate.onTransactionCommit` is what eventually triggers `v.flush()`? In my debugging I'm seeing that the error occurs when `v.flush()` is called inside the `onTransactionCommit` callback
Yes, exactly! The flow is:
1. The `arrayViewFactory` sets up a callback that calls `v.flush()`
2. This callback is registered via `this.#delegate.onTransactionCommit`
3. When a transaction commits, `this.#delegate` calls all registered callbacks
4. That triggers `v.flush()`
Since you're seeing an issue when `v.flush()` is called, let's look at the `flush` method in `ArrayView`:
```typescript
flush() {
if (!this.#dirty) {
return;
}
this.#dirty = false;
this.#fireListeners();
}
```
And this calls `#fireListeners()`:
```typescript
#fireListeners() {
for (const listener of this.#listeners) {
this.#fireListener(listener);
}
}
```
If you're seeing issues here, we might want to add some debugging to track:
1. What triggers the transaction commit
2. The state of the view (`#dirty`, `#complete`, `#root`) before `flush` is called
3. What happens during `#fireListeners()`
Would you like to add some debugging at these points to track down where things are going wrong?
I've got all that debugging added. What is `this.#delegate`?
`this.#delegate` in `QueryImpl` is of type `QueryDelegate`, which is defined in the same file:
```typescript
export type QueryDelegate extends BuilderDelegate {
addServerQuery(ast: AST, gotCallback?: GotCallback | undefined): () => void;
onTransactionCommit(cb: CommitListener): () => void;
batchViewUpdates<T>(applyViewUpdates: () => T): T;
}
```
It's an interface that handles communication with the server and transaction management. It gets passed into the `QueryImpl` constructor:
```typescript
export class QueryImpl<TSchema extends TableSchema, TReturn extends QueryType> {
readonly #delegate: QueryDelegate;
constructor(
delegate: QueryDelegate, // Passed in here
schema: NormalizedTableSchema,
ast: AST = {table: schema.tableName},
format?: Format | undefined,
) {
super(schema, ast, format);
this.#delegate = delegate;
this.#ast = ast;
}
}
```
The actual implementation of `QueryDelegate` would be created when setting up your database connection/client. Do you have access to where `QueryDelegate` is implemented? It would likely be in a file that sets up your database connection.
This delegate pattern allows the query system to communicate with the database without needing to know the specifics of how that communication happens.
I added the `context.ts` file that looks like it implements the `QueryDelegate`
Ah yes! Now we can see how the transaction commit system works. Looking at the key parts:
1. `ZeroContext` maintains a set of commit listeners:
```typescript
readonly #commitListeners: Set<CommitListener> = new Set();
```
2. When `onTransactionCommit` is called (which happens in our `arrayViewFactory`), it adds the callback to this set:
```typescript
onTransactionCommit(cb: CommitListener): () => void {
this.#commitListeners.add(cb);
return () => {
this.#commitListeners.delete(cb);
};
}
```
3. When changes are processed, it eventually calls `#endTransaction`:
```typescript
#endTransaction() {
for (const listener of this.#commitListeners) {
listener(); // This is where v.flush() gets called
}
}
```
So the full flow is:
1. `arrayViewFactory` sets up a callback to call `v.flush()`
2. This callback gets added to `#commitListeners` in `ZeroContext`
3. When `processChanges` is called (which happens when data changes), it processes the changes and then calls `#endTransaction`
4. `#endTransaction` calls all listeners, which triggers `v.flush()`
If you're seeing issues when `v.flush()` is called, it might be worth adding logging in `processChanges` to see what changes are being processed right before the flush happens. Would you like to add some debugging there?
I added two files `view-syncer.ts` and `cvr-store.ts`. It seems like this issue is due to React Strict mode. I think what is happening is that when the page loads, a query is, I'll use the term 'mounted', and starts to fetch data. Strict mode triggers some sort of 'unmounting' of this query which causes `v.flush()` to eventually get called.
In `view-syncer` we have this block of code in `#addAndRemoveQueries` which is signaling to remove the data
```ts
// Removing queries is easy. The pipelines are dropped, and the CVR
// updater handles the updates and pokes.
for (const q of removeQueries) {
this.#pipelines.removeQuery(q.transformationHash);
}
```
But the question I have is one step before that, in how do queries get added into `removeQueries`?
Let's trace backwards from how `removeQueries` is populated. In `#syncQueryPipelineSet`, we can see how `removeQueries` is determined:
```typescript
const removeQueries = serverQueries.filter(q => !q.desired);
```
So a query gets into `removeQueries` if it's in `serverQueries` but is not `desired`. Looking at how `serverQueries` is created:
```typescript
const serverQueries = Object.entries(cvr.queries).map(([id, q]) => {
// ... transform query stuff ...
return {
id,
ast: query,
transformationHash,
desired: q.internal || Object.keys(q.desiredBy).length > 0, // HERE
};
});
```
A query is considered `desired` if either:
1. It's an internal query (`q.internal` is true) OR
2. It has entries in its `desiredBy` object (`Object.keys(q.desiredBy).length > 0`)
So when React Strict Mode unmounts the component, it must be triggering some cleanup that removes the client from the query's `desiredBy` object, which then causes the query to be considered "not desired" and thus gets added to `removeQueries`.
Would you like to look at how the `desiredBy` object gets updated when a component unmounts?
Yes I ould you like to look at how the `desiredBy` object gets updated when a component unmounts
From what we've seen, when a React component unmounts, it would trigger the cleanup function returned by `useSyncExternalStore`. Let's trace this chain:
1. In `use-query.tsx`, the cleanup happens in `ViewWrapper`:
```typescript
subscribeReactInternals = (internals: () => void): (() => void) => {
this.#reactInternals.add(internals);
this.#materializeIfNeeded();
return () => { // This is the cleanup function
this.#reactInternals.delete(internals);
if (this.#reactInternals.size === 0) {
this.#view?.destroy(); // Here's where cleanup starts
this.#view = undefined;
this.#onDematerialized();
}
};
};
```
2. When `this.#view.destroy()` is called, that's hitting the `destroy()` method on `TypedView`:
```typescript
export type TypedView<T> = {
addListener(listener: Listener<T>): () => void;
destroy(): void; // This method
readonly data: T;
};
```
However, I don't see the implementation that would update the `desiredBy` object. This would likely be in the code that implements `TypedView` or in code that handles query lifecycle. Could you share any files related to query lifecycle management or the implementation of the `destroy()` method?
Are you sure the view get destroyed? I'm thinking it doesn't get destroyed because we've seen how `view.#complete` remains `true` and the data vanishes
Ah, you make a good point! Looking back at the sequence of logs you shared:
```
messages count 0 {type: 'unknown'}
messages count 108 {type: 'complete'}
messages count 0 {type: 'complete'} // Key point
messages count 108 {type: 'complete'}
```
You're right - the fact that it returns `{type: 'complete'}` with an empty array suggests the view isn't being destroyed (which would reset to 'unknown'). Instead, something is causing the data to be cleared while maintaining the 'complete' state.
Looking back at `ArrayView`, let's focus on how the data could be cleared while maintaining the complete state:
```typescript
class ArrayView<V extends View> implements Output, TypedView<V> {
readonly #root: Entry;
#complete = false;
get data() {
return this.#root[''] as V;
}
// ...
push(change: Change): void {
this.#dirty = true;
applyChange(this.#root, change, this.#schema, '', this.#format);
}
}
```
Could you share the `applyChange` function from `view-apply-change.js`? It seems like something is pushing a change that clears the data while maintaining the complete state, rather than destroying the view entirely.
I added the `view-apply-change.ts` file
Thanks! Looking at `applyChange`, I notice something important about how it handles the 'remove' case:
```typescript
case 'remove': {
if (singular) {
assertObject(parentEntry[relationship]);
parentEntry[relationship] = undefined;
} else {
const view = getChildEntryList(parentEntry, relationship);
const {pos, found} = binarySearch(
view,
change.node.row,
schema.compareRows,
);
assert(found, 'node does not exist');
// @ts-expect-error view is readonly
view.splice(pos, 1); // Removes the item from the array
}
// Needed to ensure cleanup of operator state is fully done.
drainStreams(change.node);
break;
}
```
This explains how the data can be cleared while maintaining the 'complete' state - when a 'remove' change is pushed, it removes items from the array but doesn't affect the `#complete` flag.
Let's trace back to find what's triggering these 'remove' changes. Going back to `ViewSyncer`, we see in `#syncQueryPipelineSet`:
```typescript
const removeQueries = serverQueries.filter(q => !q.desired);
// ...
for (const q of removeQueries) {
this.#pipelines.removeQuery(q.transformationHash);
}
```
Can you share the implementation of `removeQuery` from `PipelineDriver`? I suspect this is what's generating the 'remove' changes that are clearing the data.
This might help us understand exactly what happens when React Strict Mode causes a temporary unmount - it's likely triggering a chain that marks the query as not desired, which leads to remove changes being generated, which clear the data while maintaining the 'complete' state.
I added the `pipeline-driver.ts` file
I added the `pipeline-driver.ts` file, can you see it?
Yes, I can see the `pipeline-driver.ts` file now! This helps complete the picture of what's happening during React's strict mode remount.
Looking at `removeQuery` in PipelineDriver:
```typescript
removeQuery(hash: string) {
const pipeline = this.#pipelines.get(hash);
if (pipeline) {
this.#pipelines.delete(hash);
pipeline.input.destroy();
}
}
```
When this is called, it:
1. Removes the pipeline from the tracking map
2. Destroys the input
And looking at how changes propagate in the `Pipeline` system:
```typescript
input.setOutput({
push: change => {
const streamer = this.#streamer;
assert(streamer, 'must #startAccumulating() before pushing changes');
streamer.accumulate(hash, schema, [change]);
},
});
```
So when React Strict Mode causes a temporary unmount:
1. The query is marked as not desired
2. This triggers `removeQuery`
3. Which destroys the pipeline
4. This generates 'remove' changes
5. These changes flow through to `ArrayView.push()` via the change accumulation system
6. The 'remove' changes clear the data while maintaining the 'complete' state
This explains why we see:
```
messages count 0 {type: 'complete'}
```
The data is cleared by the remove changes, but the 'complete' flag isn't touched because it's tracking the query's completeness state, not the presence of data.
Would you like to explore options for preventing this behavior during strict mode remounts?
I would like to explore those options
There are a few potential approaches we could take to prevent the data clearing during strict mode remounts:
1. **Detect Strict Mode Remounts**: We could try to detect when a removal is happening due to a strict mode remount versus a real unmount. In the `ViewWrapper`:
```typescript
subscribeReactInternals = (internals: () => void): (() => void) => {
this.#reactInternals.add(internals);
this.#materializeIfNeeded();
let strictModeCleanup = false;
return () => {
// In strict mode, React will call cleanup and then immediately resubscribe
// Give a small window for resubscription before actually cleaning up
strictModeCleanup = true;
setTimeout(() => {
if (strictModeCleanup) { // If we haven't resubscribed
this.#reactInternals.delete(internals);
if (this.#reactInternals.size === 0) {
this.#view?.destroy();
this.#view = undefined;
this.#onDematerialized();
}
}
}, 0);
};
};
```
2. **Cache the Data**: Keep a cache of the data briefly during cleanup:
```typescript
class ViewWrapper {
#cachedSnapshot: QueryResult<TReturn> | null = null;
#cacheTimeout: NodeJS.Timeout | null = null;
#onData = (snap: Immutable<Smash<TReturn>>, resultType: ResultType) => {
const data = snap === undefined
? snap
: (deepClone(snap as ReadonlyJSONValue) as Smash<TReturn>);
this.#snapshot = [data, {type: resultType}] as QueryResult<TReturn>;
this.#cachedSnapshot = this.#snapshot;
// Clear any existing cache timeout
if (this.#cacheTimeout) {
clearTimeout(this.#cacheTimeout);
this.#cacheTimeout = null;
}
for (const internals of this.#reactInternals) {
internals();
}
};
getSnapshot = () => {
// If we have no current snapshot but have a cached one, use it
if (this.#snapshot[0] === undefined && this.#cachedSnapshot) {
return this.#cachedSnapshot;
}
return this.#snapshot;
};
}
```
3. **Reference Counting**: Instead of immediately removing data when a query is "not desired", implement a reference counting system with a grace period:
```typescript
class QueryRefCounter {
#refCounts = new Map<string, number>();
#pendingRemovals = new Map<string, NodeJS.Timeout>();
addRef(queryId: string) {
const count = (this.#refCounts.get(queryId) || 0) + 1;
this.#refCounts.set(queryId, count);
// Clear any pending removal
const timeout = this.#pendingRemovals.get(queryId);
if (timeout) {
clearTimeout(timeout);
this.#pendingRemovals.delete(queryId);
}
}
removeRef(queryId: string) {
const count = (this.#refCounts.get(queryId) || 1) - 1;
if (count <= 0) {
// Wait before actually removing
this.#pendingRemovals.set(queryId, setTimeout(() => {
this.#refCounts.delete(queryId);
this.#pendingRemovals.delete(queryId);
// Actually remove the query
}, 100));
} else {
this.#refCounts.set(queryId, count);
}
}
}
```
Which approach would you like to explore further? Each has different tradeoffs:
- Option 1 is simplest but might be fragile
- Option 2 maintains data but might use more memory
- Option 3 is most robust but requires more changes to the system
Let's explore Option 1 more
Let's dive into Option 1. The idea is to detect when a cleanup is happening due to strict mode vs a real unmount by introducing a small delay to see if we get a resubscribe.
Here's a more detailed implementation for the `ViewWrapper`:
```typescript
class ViewWrapper<TSchema extends TableSchema, TReturn extends QueryType> {
#reactInternals = new Set<() => void>();
#view: TypedView<Smash<TReturn>> | undefined;
#snapshot: QueryResult<TReturn>;
#pendingCleanups = new Map<() => void, NodeJS.Timeout>();
subscribeReactInternals = (internals: () => void): (() => void) => {
// Clear any pending cleanup for this internal
const pendingCleanup = this.#pendingCleanups.get(internals);
if (pendingCleanup) {
clearTimeout(pendingCleanup);
this.#pendingCleanups.delete(internals);
}
this.#reactInternals.add(internals);
this.#materializeIfNeeded();
return () => {
// Instead of cleaning up immediately, schedule it
const timeout = setTimeout(() => {
this.#pendingCleanups.delete(internals);
this.#reactInternals.delete(internals);
// Only destroy view if this was the last listener
if (this.#reactInternals.size === 0) {
this.#view?.destroy();
this.#view = undefined;
this.#onDematerialized();
}
}, 0); // You might need to tune this timeout
this.#pendingCleanups.set(internals, timeout);
};
};
// Add cleanup for component unmount
destroy() {
// Clear all pending cleanups
for (const timeout of this.#pendingCleanups.values()) {
clearTimeout(timeout);
}
this.#pendingCleanups.clear();
// Actually cleanup
this.#view?.destroy();
this.#view = undefined;
}
}
```
We can test this by adding some logging:
```typescript
subscribeReactInternals = (internals: () => void): (() => void) => {
console.log('Subscribing new internal');
const pendingCleanup = this.#pendingCleanups.get(internals);
if (pendingCleanup) {
console.log('Caught resubscribe during strict mode remount!');
clearTimeout(pendingCleanup);
this.#pendingCleanups.delete(internals);
}
this.#reactInternals.add(internals);
this.#materializeIfNeeded();
return () => {
console.log('Cleanup called, delaying to check for strict mode');
const timeout = setTimeout(() => {
console.log('No resubscribe detected, performing actual cleanup');
this.#pendingCleanups.delete(internals);
this.#reactInternals.delete(internals);
if (this.#reactInternals.size === 0) {
this.#view?.destroy();
this.#view = undefined;
this.#onDematerialized();
}
}, 0);
this.#pendingCleanups.set(internals, timeout);
};
};
```
This approach:
1. Delays cleanup when a subscriber is removed
2. Cancels the delayed cleanup if the same subscriber resubscribes
3. Only performs the actual cleanup if no resubscription happens
Would you like to try implementing this and see if it prevents the data clearing during strict mode remounts?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment