diff --git a/packages/bippy/package.json b/packages/bippy/package.json index 52b595a6..34ca5473 100644 --- a/packages/bippy/package.json +++ b/packages/bippy/package.json @@ -109,6 +109,8 @@ "react-inspector": "^6.0.2", "react-reconciler": "^0.31.0", "react-refresh": "^0.16.0", + "react-router": "^6.26.0", + "react-router-dom": "^6.26.0", "terser": "^5.36.0", "tsup": "^8.2.4", "vitest": "^2.1.8" diff --git a/packages/bippy/src/core.ts b/packages/bippy/src/core.ts index 317467a5..c0b5572e 100644 --- a/packages/bippy/src/core.ts +++ b/packages/bippy/src/core.ts @@ -399,25 +399,93 @@ export const getNearestHostFibers = (fiber: Fiber): Fiber[] => { return hostFibers; }; +// biome-ignore lint/suspicious/noConfusingVoidType: +export type FiberSelector = (node: Fiber) => boolean | void; + +export interface TraverseFiberOptions { + /** + * The handler to call when entering a fiber, return `true` to stop and select a node. + */ + enter?: FiberSelector; + /** + * The handler to call when exiting a fiber, return `true` to stop and select a node. + */ + leave?: FiberSelector; + /** + * Whether to traverse the fiber tree in ascending order. + */ + ascending?: boolean; +} + +export interface TraverseFiber { + ( + fiber: Fiber | null, + /** + * The handler to call when entering a fiber. + */ + selector: FiberSelector, + /** @deprecated In favor of `options.ascending`. */ + ascending?: boolean, + ): Fiber | null; + (fiber: Fiber | null, options: TraverseFiberOptions): Fiber | null; + ( + fiber: Fiber | null, + selectorOrOpts: FiberSelector | TraverseFiberOptions, + ascendingOrNever?: boolean, + ): Fiber | null; +} + /** * Traverses up or down a {@link Fiber}, return `true` to stop and select a node. */ -export const traverseFiber = ( - fiber: Fiber | null, - // biome-ignore lint/suspicious/noConfusingVoidType: may or may not exist - selector: (node: Fiber) => boolean | void, - ascending = false, -): Fiber | null => { +export const traverseFiber: TraverseFiber = ( + fiber, + selectorOrOpts, + ascendingOrNever = false, +) => { if (!fiber) return null; - if (selector(fiber) === true) return fiber; + let enter: FiberSelector | undefined; + let leave: FiberSelector | undefined; + let ascending = false; - let child = ascending ? fiber.return : fiber.child; - while (child) { - const match = traverseFiber(child, selector, ascending); - if (match) return match; + if (typeof selectorOrOpts === 'function') { + enter = selectorOrOpts; + if (typeof ascendingOrNever === 'boolean') ascending = ascendingOrNever; + } else { + enter = selectorOrOpts.enter; + leave = selectorOrOpts.leave; + ascending = selectorOrOpts.ascending ?? false; + } + + const stack: Fiber[] = [fiber]; + const visited = new Set(); - child = ascending ? null : child.sibling; + while (stack.length > 0) { + const current = stack[stack.length - 1]; + + if (!visited.has(current)) { + visited.add(current); + + // Trigger enter handler only once per fiber. + if (enter && enter(current) === true) return current; + + // Keep going down the tree. We will back up later. + const next = ascending ? current.return : current.child; + if (next) { + stack.push(next); + continue; + } + } + + // Go back to the visited parent fiber and trigger leave handler. + stack.pop(); + + if (leave && leave(current) === true) return current; + + const sibling = ascending ? null : current.sibling; + if (sibling) stack.push(sibling); } + return null; }; diff --git a/packages/bippy/src/test/core/fiber.test.tsx b/packages/bippy/src/test/core/fiber.test.tsx index dfa7c687..ea299027 100644 --- a/packages/bippy/src/test/core/fiber.test.tsx +++ b/packages/bippy/src/test/core/fiber.test.tsx @@ -1,5 +1,10 @@ - -import { describe, expect, it } from 'vitest'; +// import bippy, then react +import { expect, it, describe, vi } from 'vitest'; +await import('../../index.js'); +// biome-ignore lint/correctness/noUnusedVariables: needed for JSX +const React = require('react'); +import { createMemoryRouter, type UNSAFE_DataRouterContext, Outlet, RouterProvider, useLoaderData, type DataRouteMatch } from 'react-router-dom'; +import type { Fiber, FiberRoot } from '../../types.js'; import { didFiberCommit, didFiberRender, @@ -15,12 +20,9 @@ import { isValidFiber, traverseFiber, } from '../../index.js'; -import type { Fiber } from '../../types.js'; // FIXME(Alexis): Both React and @testing-library/react should be after index.js // but the linter/import sorter keeps moving them on top -// biome-ignore lint/correctness/noUnusedImports: needed for JSX -import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, type RenderOptions, screen, waitFor } from '@testing-library/react'; import { BasicComponent, BasicComponentWithChildren, @@ -279,6 +281,279 @@ describe('traverseFiber', () => { ), ).toBe((maybeFiber as unknown as Fiber)?.child); }); + + + const onCommitFiberRoot = vi.fn(); + instrument({ onCommitFiberRoot }); + + const renderWithFiber = (ui: React.ReactNode, options?: RenderOptions) => { + const result = render(ui, options); + const fiber: FiberRoot = onCommitFiberRoot.mock.lastCall?.[1]; + return { ...result, fiber }; + }; + + const { fiber } = renderWithFiber( +
+
+
+
+
+
+
+
+
+
+
+
+
, + ); + + it('should traverse a fiber', () => { + const order: string[] = []; + traverseFiber(fiber.current, fiber => { + fiber.key && order.push(fiber.key); + }); + expect(order).toEqual([ + 'root', + 'a', + 'a1', + 'a2', + 'b', + 'c', + 'd', + 'd1', + 'd11', + ]); + }); + + it('should traverse a fiber in reverse', () => { + const order: string[] = []; + const d11 = traverseFiber(fiber.current, fiber => fiber.key === 'd11'); + expect(d11?.key).toBe('d11'); + + traverseFiber( + d11, + fiber => { + fiber.key && order.push(fiber.key); + }, + true, + ); + expect(order).toEqual(['d11', 'd1', 'd', 'root']); + }); + + it('should traverse a fiber with entry and leave handlers', () => { + const enterOrder: string[] = []; + const leaveOrder: string[] = []; + traverseFiber(fiber.current, { + enter: fiber => { + fiber.key && enterOrder.push(fiber.key); + }, + leave: fiber => { + fiber.key && leaveOrder.push(fiber.key); + }, + }); + expect(enterOrder).toEqual([ + 'root', + 'a', + 'a1', + 'a2', + 'b', + 'c', + 'd', + 'd1', + 'd11', + ]); + expect(leaveOrder).toEqual([ + 'a1', + 'a2', + 'a', + 'b', + 'c', + 'd11', + 'd1', + 'd', + 'root', + ]); + }); + + it('should traverse a fiber with entry and leave handlers in reverse', () => { + const d11 = traverseFiber(fiber.current, fiber => fiber.key === 'd11'); + expect(d11?.key).toBe('d11'); + + const enterOrder: string[] = []; + const leaveOrder: string[] = []; + traverseFiber(d11, { + ascending: true, + enter: fiber => { + fiber.key && enterOrder.push(fiber.key); + }, + leave: fiber => { + fiber.key && leaveOrder.push(fiber.key); + }, + }); + expect(enterOrder).toEqual(['d11', 'd1', 'd', 'root']); + expect(leaveOrder).toEqual(['root', 'd', 'd1', 'd11']); + }); + + it('should traverse a fiber and get stack', () => { + const stack: Fiber[] = []; + traverseFiber(fiber.current, { + enter: fiber => { + if (fiber.key === 'd11') { + const keys = stack.map(fiber => fiber.key).filter(Boolean); + expect(keys).toEqual(['root', 'd', 'd1']); + } + + stack.push(fiber); + }, + leave: fiber => { + const last = stack.pop(); + expect(last).toBe(fiber); + }, + }); + expect(stack).toEqual([]); + }); + + + it('should traverse fibers of Data Router', async () => { + const router = createMemoryRouter([ + { + path: "/", + loader: async () => { + return { message: "Root layout data" }; + }, + Component: function RootLayout() { + const { message } = useLoaderData() as { message: string }; + return ( +
+ +
+

{message}

+ +
+
+ ); + }, + children: [ + { + index: true, + loader: async () => { + return { message: "Home page data" }; + }, + Component: function HomePage() { + const { message } = useLoaderData() as { message: string }; + return

{message}

; + }, + }, + { + path: "user", + loader: async () => { + return { message: "User layout data" }; + }, + Component: function UserLayout() { + const { message } = useLoaderData() as { message: string }; + return ( +
+

{message}

+ +
+ ); + }, + children: [ + { + index: true, + loader: async () => { + return { users: ["user1", "user2", "user3"] }; + }, + Component:function UserListPage() { + const { users } = useLoaderData() as { users: string[] }; + return ( +
    + {users.map(user => ( +
  • {user}
  • + ))} +
+ ); + }, + }, + { + path: ":id", + loader: async ({ params }) => { + return { userId: params.id }; + }, + Component: function UserDetailPage() { + const { userId } = useLoaderData() as { userId: string }; + return

User ID: {userId}

; + }, + }, + ], + }, + ], + }, + ], { initialEntries: ['/', '/user', '/user/123'], future: { v7_relativeSplatPath: true } }); + + + const onCommitFiberRoot = vi.fn(); + instrument({ onCommitFiberRoot }); + + const { getByTestId } = render() + await waitFor(() => Promise.resolve()); + const root = onCommitFiberRoot.mock.lastCall?.[1]; + + const cb = vi.fn(); + + type UnwrapContext = T extends React.Context ? NonNullable : never; + type DataRouter = UnwrapContext['router']; + interface RouterData { + fiber: Fiber; + router: DataRouter; + matches: (DataRouteMatch & { element?: Element })[] + } + const returns: Fiber[] = []; + const routeFibers: Fiber[] = []; + let routerData: RouterData | null = null; + traverseFiber(root.current, { + enter(fiber) { + if (isRouterProviderFiber(fiber)) { + const router = fiber.memoizedProps.router as DataRouter; + const matches = router.state.matches.map(match => ({ ...match })); + routerData = { fiber, router, matches } + } + + if (isRenderedRouteFiber(fiber)) + routeFibers.unshift(fiber); + returns.unshift(fiber); + }, + leave(fiber) { + if (isRenderedRouteFiber(fiber)) + routeFibers.shift(); + returns.shift(); + + if (fiber === routerData?.fiber) { + cb(routerData); + routerData = null; + } + + if (routerData && fiber.stateNode instanceof Element) { + for (let i = 0; i < routeFibers.length; i++) { + routerData.matches[i].element = fiber.stateNode; + } + } + }, + }); + expect(cb).toHaveBeenCalledTimes(1); + + const data = cb.mock.lastCall?.[0] as RouterData; + expect(data).toEqual(expect.objectContaining({ + fiber: expect.any(Object), + router: expect.any(Object), + matches: expect.arrayContaining([ + expect.objectContaining({ pathname: '/', element: getByTestId('root-layout') }), + expect.objectContaining({ pathname: '/user', element: getByTestId('user-layout') }), + expect.objectContaining({ pathname: '/user/123', element: getByTestId('user-detail-page') }), + ]) + })) + }); }); describe('getFiberFromHostInstance', () => { @@ -289,3 +564,11 @@ describe('getFiberFromHostInstance', () => { expect(fiber?.type).toBe('div'); }); }); + +function isRouterProviderFiber(fiber: Fiber) { + return fiber.type === RouterProvider; +} + +function isRenderedRouteFiber(fiber: Fiber) { + return fiber.type?.name === 'RenderedRoute'; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ae976aa..37dd5e1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,12 @@ importers: react-refresh: specifier: ^0.16.0 version: 0.16.0 + react-router: + specifier: ^6.26.0 + version: 6.28.2(react@19.0.0) + react-router-dom: + specifier: ^6.26.0 + version: 6.28.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) terser: specifier: ^5.36.0 version: 5.36.0 @@ -1165,6 +1171,10 @@ packages: peerDependencies: react: '>=16.8' + '@remix-run/router@1.21.1': + resolution: {integrity: sha512-KeBYSwohb8g4/wCcnksvKTYlg69O62sQeLynn2YE+5z7JWEj95if27kclW9QqbrlsQ2DINI8fjbV3zyuKfwjKg==} + engines: {node: '>=14.0.0'} + '@rollup/rollup-android-arm-eabi@4.28.0': resolution: {integrity: sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==} cpu: [arm] @@ -2464,6 +2474,19 @@ packages: '@types/react': optional: true + react-router-dom@6.28.2: + resolution: {integrity: sha512-O81EWqNJWqvlN/a7eTudAdQm0TbI7hw+WIi7OwwMcTn5JMyZ0ibTFNGz+t+Lju0df4LcqowCegcrK22lB1q9Kw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.28.2: + resolution: {integrity: sha512-BgFY7+wEGVjHCiqaj2XiUBQ1kkzfg6UoKYwEe0wv+FF+HNPCxtS/MVPvLAPH++EsuCMReZl9RYVGqcHLk5ms3A==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -3963,6 +3986,8 @@ snapshots: dependencies: react: 19.0.0 + '@remix-run/router@1.21.1': {} + '@rollup/rollup-android-arm-eabi@4.28.0': optional: true @@ -5227,6 +5252,18 @@ snapshots: optionalDependencies: '@types/react': 19.0.4 + react-router-dom@6.28.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@remix-run/router': 1.21.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-router: 6.28.2(react@19.0.0) + + react-router@6.28.2(react@19.0.0): + dependencies: + '@remix-run/router': 1.21.1 + react: 19.0.0 + react-style-singleton@2.2.3(@types/react@19.0.4)(react@19.0.0): dependencies: get-nonce: 1.0.1 diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 4c009625..00000000 --- a/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "jsx": "react", - "module": "NodeNext", - "esModuleInterop": true, - "strictNullChecks": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "lib": ["esnext", "dom"] - }, - "include": ["packages"], - "exclude": ["**/node_modules/**", "**/dist/**"] -}