Skip to content

add componentId - #349

Open
kyeotic wants to merge 4 commits into
bikeshaving:mainfrom
kyeotic:tkye.component-id
Open

add componentId#349
kyeotic wants to merge 4 commits into
bikeshaving:mainfrom
kyeotic:tkye.component-id

Conversation

@kyeotic

@kyeotic kyeotic commented Feb 26, 2026

Copy link
Copy Markdown

Closes #325

  • Adds to components
  • Adds renderer options with to control id generation
  • adds tests to verify fast and stable mode produce valid identifiers

The reason I think fast is valuable is that hashing the function.toString() can get expensive. It is also a warning in some security analysis tools. fast produces monotonic IDs, which will be internally stable but not stable across renders or instances (order could change which number a component gets). The default mode is fast, but I'm not married to this.

Closes bikeshaving#325

- Adds  to components
- Adds renderer options with  to control id generation
- adds tests to verify fast and stable mode produce valid identifiers

The reason I think  is valuable is that hashing the function.toString() can get expensive. It is also a warning in some security analysis tools.  produces monotonic IDs, which will be internally stable but not stable across renders or instances (order could change which number a component gets). The default mode is , but I'm not married to this.
@kyeotic

kyeotic commented Feb 26, 2026

Copy link
Copy Markdown
Author

I just realized this won't discriminate between two different wrapped versions of a HOC. I'll add a test case, and try to come up with a solve for that in the morning.

Comment thread src/crank.ts Outdated
Comment thread src/crank.ts
// keys are roots
const afterMapByRoot = new WeakMap<object, Map<ContextState, Set<Function>>>();

function djb2Hash(str: string): string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should go in _utils, likely.

Comment thread src/crank.ts
generate: (fn: Function) => string;
}

const componentIdStates = new WeakMap<object, ComponentIdState>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an impressive usage of WeakMap that I’ll probably have to think about.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mentioned WeakMap in your original design, I figured this was exactly what you meant. Did you have something else in mind?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was spitballing. I enjoy a good WeakMap I guess.

Comment thread src/crank.ts Outdated
Comment thread package-lock.json Outdated

@brainkim brainkim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only major concern I have is adding options to the Renderer interface. My instincts say pick a single, optimal component id system. Yes, figuring out the HOC situation is tricky, eager to see your solve.

@kyeotic

kyeotic commented Feb 27, 2026

Copy link
Copy Markdown
Author

Hey, sorry for the delay on updating this. I was one of the 40% of people laid off from Block yesterday. Yay 🙃

So, I added a test for componentIds from HOCs and... it works. I think I was letting late night brain do the thinking, because of course it works, at least in fast. The functions may have identical contents but they still differ by identity. They are unique functions, so they get ids.

However, this seems like it should only work in fast mode with the monotonic counter. With the hashing algo in stable I would expect it to fail, but it still doesn't. This surprised me.

// test harness
function Hoc(Component: any) {
	return function Wrapped(props: any) {
		console.log('HOC internal', this.componentId) // this is always the same
		return <Component {...props} />;
	};
}

const ComponentA = Hoc(function ComponentA(this: Context) {
	idA = this.componentId;
	return <div />;
});

const ComponentB = Hoc(function ComponentB(this: Context) {
	idB = this.componentId;
	return <div />;
});

const ComponentC = Hoc((props, ctx) => {
	idC = ctx.componentId;
	return <div />;
});

So because ComponentA is the HOC result, I would expect it to stringify the same as ComponentB . It doesn't though, they stringify like this

getting ID ->crank-1jqrbs6
function ComponentA2() {
        idA = this.componentId;
        return /* @__PURE__ */ createElement("div", null);
      }

getting ID -> crank-1dsm5oo
function ComponentB2() {
        idB = this.componentId;
        return /* @__PURE__ */ createElement("div", null);
      }

getting ID -> crank-1rgxp5p
(props, ctx) => {
        idC = ctx.componentId;
        return /* @__PURE__ */ createElement("div", null);
      }

There are other logs, for the string IDs from the HOC component, and they are all the same. This is not ideal, but the impact is not as bad as I thought it would be. Only the Wrapped component itself shares its componentId, the components being wrapped still get a unique componentId internally.

I imagine you want the Wrapped component to still get a unique ID based on the component it is wrapping, such that this was a different ID for each wrapper

function Hoc(Component: any) {
		return function Wrapped(props: any) {
			console.log('HOC internal', this.componentId) // this is always the same
			return <Component {...props} />;
		};
	}

One way to do that is to provide an API to the Wrapped component that would allow it to override its id hash input, e.g.

function Hoc(Component: any) {
	function Wrapped(props: any) {
		console.log('HOC internal', this.componentId) // this is always the same
		return <Component {...props} />;
	};
	Wrapped.componentIdHash = Component.toString() + 'HOC';
	return Wrapped;
}
	
// OR
	
function Hoc(Component: any) {
	return function Wrapped(props: any) {
		this.componentIdHash = Component.toString() + 'HOC';
		console.log('HOC internal', this.componentId) // this is always the same
		return <Component {...props} />;
	};
}

This isn't automatic, though it is still maybe the best solution.

Here are some hacky ideas.

  1. Use a stack trace to create a unique hash input. This would allow the componentId tool to identify the different outer wrappers. It would not always be stable though, since it will depend on the order in which the component is called. It might be possible to mitigate this by controlling the number of parent frames to inspect, though that could fail if there are multiple layers of wrapping needed to find a unique parent. Its also terrible for performance to generate stack traces for every component.
  2. Use a collision counter suffix. Since the weakmap detects that a new function identity generated a colliding hash, we could check against this and append a monotonic suffix. This will never be stable though, since it always depend on the order in which collisions occured.

@brainkim

Copy link
Copy Markdown
Member

Darn about the current events! I’m sorry to hear that. Happy Friday: I will try to review this soon!

I wish you success this year. I hope you show ’em what’s what.

@brainkim

brainkim commented Mar 2, 2026

Copy link
Copy Markdown
Member

Happy Monday! This may be uninformed ignorance on my part, but what are the arguments against using referential identity for functions? That would be fast-ish (lookup in WeakMap on every componentId access) and stable?

This is also going to likely be key for hot reload when we eventually open that can of worms. It’s very important to get right, so thank you for your thoughts.

@kyeotic

kyeotic commented Mar 2, 2026

Copy link
Copy Markdown
Author

This does use referential identity for functions when looking up or storing the componentId hash. It is fast. Stability depends on the value though, not the lookup mechanism. To generate the hash value you need a string, and the function identity is not a string. The toString() of the function is a string, but its not unique for the HOC wrapper.

Stability in this context means that the hash will be the same when you

  • refresh the page
  • Run on the server, then run on the client
  • Run in a service worker

Getting those all to give you the same output means that the inputs cannot depend on the order components are rendered, since that could change on the server, or even on the client if async tasks resolve races differently.

@brainkim brainkim mentioned this pull request Jun 24, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose per-definition component identifier

2 participants