Skip to content

Fix v0.16 codemod: add shadow checks to transformUseFetch#3836

Merged
ntucker merged 17 commits intomasterfrom
docs/v016-codemod
Mar 30, 2026
Merged

Fix v0.16 codemod: add shadow checks to transformUseFetch#3836
ntucker merged 17 commits intomasterfrom
docs/v016-codemod

Conversation

@ntucker
Copy link
Copy Markdown
Collaborator

@ntucker ntucker commented Mar 29, 2026

Motivation

Users upgrading to v0.16 face three breaking changes that require manual code updates across their codebase. An automated codemod makes this migration significantly easier and less error-prone.

Solution

Add a jscodeshift codemod served as a static asset at dataclient.io/codemods/v0.16.js. Users can run it directly via remote URL:

npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/

The codemod handles all three breaking changes:

  1. path-to-regexp v6 → v8/:param?{/:param}, /:name+/*name, /:name*{/*name}, inline regex removal, escape cleanup
  2. useFetch() truthiness → .resolvedif (promise)if (!promise.resolved) in if/ternary/&& expressions
  3. schema.X namespace → direct importsnew schema.Union(...)new Union(...) with import rewriting (including TS type positions)

The blog post migration guide section now links to the codemod with a copy-pasteable one-liner.

Open questions

N/A

Made with Cursor


Note

Medium Risk
The new codemod performs non-trivial AST rewrites across user code, so incorrect matching or shadowing edge cases could cause unintended transformations; site/docs changes themselves are low risk.

Overview
Adds a hosted jscodeshift codemod at website/static/codemods/v0.16.js to automate the v0.16 migration: rewrites RestEndpoint/resource path strings for path-to-regexp v8, converts useFetch() truthiness checks to .resolved comparisons (with identifier shadowing safeguards), and replaces schema.X member access with direct schema imports (including TS type positions).

Updates the v0.16 release announcement migration guide to link to the codemod and provide a copy-paste npx jscodeshift -t https://dataclient.io/codemods/v0.16.js ... command.

Written by Cursor Bugbot for commit b73054e. This will update automatically on new commits. Configure here.

jscodeshift transform served from dataclient.io/codemods/v0.16.js
handling path-to-regexp v8 syntax, useFetch() .resolved, and
schema namespace to direct imports. Linked from the blog post
migration guide.

Made-with: Cursor
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 29, 2026

⚠️ No Changeset found

Latest commit: b73054e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs-site Ready Ready Preview, Comment Mar 30, 2026 0:09am

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 29, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.07%. Comparing base (be9c531) to head (b73054e).
⚠️ Report is 8 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #3836   +/-   ##
=======================================
  Coverage   98.07%   98.07%           
=======================================
  Files         151      151           
  Lines        2855     2855           
  Branches      561      561           
=======================================
  Hits         2800     2800           
  Misses         11       11           
  Partials       44       44           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…n same declaration

When a name like Entity is both directly imported and used via schema.Entity
in the same import declaration, the codemod had two bugs:

1. scopeBindings included specifiers from the same import declaration (other
   than the schema specifier itself), causing resolveLocal to incorrectly
   detect a conflict and rename schema.Entity to SchemaEntity. Fixed by
   skipping the entire current import declaration when building scopeBindings,
   since those names come from the same package and won't conflict.

2. The existing-import dedup check compared imported names (e.g. 'Entity')
   rather than local names. When an alias was needed (Entity as SchemaEntity),
   finding 'Entity' already present caused the aliased specifier to be
   skipped, leaving SchemaEntity undefined. Fixed by checking existingLocals
   (s.local.name) against localName instead.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
transformUseFetch previously collected variable names into a file-global
set, then rewrote every matching identifier in any if/ternary/&& across
the entire file. If two components in the same file both declared
'const data = ...' — one from useFetch() and one from an unrelated
source — the non-useFetch truthiness check would be incorrectly
rewritten to '!data.resolved'.

Fix by walking up to the enclosing function for each useFetch()
declarator and only rewriting truthiness checks within that scope.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
The rewrite function transformed `if (promise)` into `if (!promise.resolved)`,
but useFetch returns undefined when called with null args (conditional fetching).
Accessing .resolved on undefined throws a TypeError at runtime.

Use optional chaining (promise?.resolved) to guard against undefined.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor cursor bot changed the title docs: Add v0.16 migration codemod fix(codemod): use optional chaining in useFetch truthiness rewrite Mar 29, 2026
The LogicalExpression handler only called rewrite(p.node.left), so a
useFetch variable on the right side of && or || (e.g. 'condition &&
promise') was never transformed. In v0.16, promise is always a truthy
UsablePromise object when args are non-null, so untransformed right-side
checks silently became no-ops.

Now both left and right sides are rewritten, and || expressions are
handled alongside &&.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…exist

The transformSchemaImports function was unconditionally removing the
schema import specifier after rewriting schema.X member expressions,
but standalone references to 'schema' as a bare identifier (destructuring,
function arguments, typeof) were left unrewritten, causing ReferenceError
at runtime.

Now checks for remaining bare references before removing the import.
If any exist, the schema specifier is kept while still adding the
individual named imports for the rewritten member expressions.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@ntucker ntucker changed the title fix(codemod): use optional chaining in useFetch truthiness rewrite docs: Add codemod for 0.16 upgrade Mar 30, 2026
Object and Array are type-only exports from @data-client/endpoint
(export type { Array, Object }), not value exports. The codemod was
transforming schema.Object/schema.Array into direct imports like
'import { Object as SchemaObject }', which would fail in value
positions (e.g. new SchemaObject(...)). These members must remain
accessed via the schema namespace.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…ata-client

transformUseFetch previously matched any call named useFetch without verifying
it was imported from a @data-client package. Since useFetch is a common hook
name exported by other libraries (e.g. use-http), running this codemod on
codebases using a different useFetch would silently corrupt truthiness checks.

Added an import-source guard that checks for a useFetch import from any
@data-client/* package before applying the transformation, consistent with
how transformSchemaImports gates on DATA_CLIENT_PACKAGES.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor cursor bot changed the title docs: Add codemod for 0.16 upgrade Fix v0.16 codemod: gate transformUseFetch on @data-client import Mar 30, 2026
…l binding

transformSchemaImports was replacing ALL schema.X member expressions
file-wide without checking whether 'schema' at each usage site actually
referred to the import or to a local variable/parameter that shadows it.

For example, a function parameter named 'schema' (e.g.,
function validate(schema) { return schema.isValid; }) in the same file
as import { schema } from '@data-client/endpoint' would have
schema.isValid incorrectly replaced with a bare isValid identifier.

Add isShadowed() helper that walks up the AST from each schema.X node
to check for shadowing bindings in enclosing scopes (function params,
catch clauses, variable declarations, for-loop bindings). Skip the
replacement when the identifier is shadowed.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor cursor bot changed the title Fix v0.16 codemod: gate transformUseFetch on @data-client import Fix v0.16 codemod: gate transformUseFetch on @data-client import, skip shadowed schema refs Mar 30, 2026
…ching

The rewrite function transformed:
- `promise` → `!promise?.resolved`
- `!promise` → `promise?.resolved`

This inverts boolean semantics when useFetch returns undefined (null args
for conditional fetching). `undefined?.resolved` evaluates to `undefined`
via optional chaining, so `!undefined` is `true` — but the original
`if (promise)` with `undefined` was `false`.

Fix by using strict equality against `false`:
- `promise` → `promise?.resolved === false`
- `!promise` → `promise?.resolved !== false`

This preserves the original semantics for all three states: in-flight
promise (resolved=false), resolved promise (resolved=true), and disabled
fetch (undefined).

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…ndings

scopeBindings only collected local names from import declarations, missing
top-level variable, function, and class declarations. This caused
resolveLocal to miss naming conflicts (e.g. const Union = someValue),
producing invalid code with duplicate bindings.

Now collects bindings from:
- Top-level VariableDeclarations (including destructured patterns)
- Top-level FunctionDeclarations
- Top-level ClassDeclarations
- Exported variants of all the above

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…n isShadowed

The isShadowed function only checked VariableDeclaration statements when
scanning BlockStatement/Program nodes. This missed FunctionDeclaration and
ClassDeclaration names that also create bindings in block scope.

When a local function or class with the same name as the import alias
(e.g. 'schema') existed in an enclosing block, isShadowed incorrectly
returned false, causing the codemod to transform references to those
local bindings.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
transformUseFetch searched the entire enclosing function for truthiness
checks on the useFetch variable name, but never checked whether that
name was shadowed by a parameter or local variable in a nested function
or callback. This caused incorrect rewrites like adding .resolved to a
callback parameter that happened to share the same name.

Fix: reuse the existing isShadowed() helper (already used by
transformSchemaImports) with an optional stopNode parameter to limit the
shadow walk to scopes between the found expression and the function
where useFetch was declared.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor cursor bot changed the title Fix v0.16 codemod: gate transformUseFetch on @data-client import, skip shadowed schema refs Fix v0.16 codemod: add shadow checks to transformUseFetch Mar 30, 2026
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

transformPaths was transforming every StringLiteral with a 'path' property
key in the entire file, regardless of whether the file uses @data-client.
This could silently corrupt path strings from unrelated libraries (React
Router, Express, etc.) like /:param? → {/:param}.

Add the same DATA_CLIENT_PACKAGES import guard that transformUseFetch
already uses, so transformPaths only runs on files that actually import
from @data-client/endpoint or @data-client/rest.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…licts

resolveLocal prefixed a name with 'Schema' when it collided with a JS
global or scope binding, but never verified the resulting identifier was
itself free of collisions. If the user had an existing binding named
e.g. SchemaUnion, the codemod would silently produce a conflicting
identifier.

Now the function loops, prepending '_' to the candidate until it finds
an identifier that doesn't collide with JS_GLOBALS or scopeBindings.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
pull bot pushed a commit to erickirt/data-client that referenced this pull request Mar 30, 2026
)

* docs: Add v0.16 migration codemod

jscodeshift transform served from dataclient.io/codemods/v0.16.js
handling path-to-regexp v8 syntax, useFetch() .resolved, and
schema namespace to direct imports. Linked from the blog post
migration guide.

Made-with: Cursor

* fix: bugbot

* Fix schema codemod: aliased import skipped when name already exists in same declaration

When a name like Entity is both directly imported and used via schema.Entity
in the same import declaration, the codemod had two bugs:

1. scopeBindings included specifiers from the same import declaration (other
   than the schema specifier itself), causing resolveLocal to incorrectly
   detect a conflict and rename schema.Entity to SchemaEntity. Fixed by
   skipping the entire current import declaration when building scopeBindings,
   since those names come from the same package and won't conflict.

2. The existing-import dedup check compared imported names (e.g. 'Entity')
   rather than local names. When an alias was needed (Entity as SchemaEntity),
   finding 'Entity' already present caused the aliased specifier to be
   skipped, leaving SchemaEntity undefined. Fixed by checking existingLocals
   (s.local.name) against localName instead.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix: scope useFetch truthiness rewrites to enclosing function

transformUseFetch previously collected variable names into a file-global
set, then rewrote every matching identifier in any if/ternary/&& across
the entire file. If two components in the same file both declared
'const data = ...' — one from useFetch() and one from an unrelated
source — the non-useFetch truthiness check would be incorrectly
rewritten to '!data.resolved'.

Fix by walking up to the enclosing function for each useFetch()
declarator and only rewriting truthiness checks within that scope.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): use optional chaining in useFetch truthiness rewrite

The rewrite function transformed `if (promise)` into `if (!promise.resolved)`,
but useFetch returns undefined when called with null args (conditional fetching).
Accessing .resolved on undefined throws a TypeError at runtime.

Use optional chaining (promise?.resolved) to guard against undefined.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): rewrite useFetch vars on both sides of LogicalExpression

The LogicalExpression handler only called rewrite(p.node.left), so a
useFetch variable on the right side of && or || (e.g. 'condition &&
promise') was never transformed. In v0.16, promise is always a truthy
UsablePromise object when args are non-null, so untransformed right-side
checks silently became no-ops.

Now both left and right sides are rewritten, and || expressions are
handled alongside &&.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): preserve schema import when bare identifier references exist

The transformSchemaImports function was unconditionally removing the
schema import specifier after rewriting schema.X member expressions,
but standalone references to 'schema' as a bare identifier (destructuring,
function arguments, typeof) were left unrewritten, causing ReferenceError
at runtime.

Now checks for remaining bare references before removing the import.
If any exist, the schema specifier is kept while still adding the
individual named imports for the rewritten member expressions.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix: bugbot

* fix(codemod): skip schema.Object and schema.Array in v0.16 transform

Object and Array are type-only exports from @data-client/endpoint
(export type { Array, Object }), not value exports. The codemod was
transforming schema.Object/schema.Array into direct imports like
'import { Object as SchemaObject }', which would fail in value
positions (e.g. new SchemaObject(...)). These members must remain
accessed via the schema namespace.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* Fix transformUseFetch to only apply when useFetch is imported from @data-client

transformUseFetch previously matched any call named useFetch without verifying
it was imported from a @data-client package. Since useFetch is a common hook
name exported by other libraries (e.g. use-http), running this codemod on
codebases using a different useFetch would silently corrupt truthiness checks.

Added an import-source guard that checks for a useFetch import from any
@data-client/* package before applying the transformation, consistent with
how transformSchemaImports gates on DATA_CLIENT_PACKAGES.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): skip schema.X rewrites where schema is shadowed by local binding

transformSchemaImports was replacing ALL schema.X member expressions
file-wide without checking whether 'schema' at each usage site actually
referred to the import or to a local variable/parameter that shadows it.

For example, a function parameter named 'schema' (e.g.,
function validate(schema) { return schema.isValid; }) in the same file
as import { schema } from '@data-client/endpoint' would have
schema.isValid incorrectly replaced with a bare isValid identifier.

Add isShadowed() helper that walks up the AST from each schema.X node
to check for shadowing bindings in enclosing scopes (function params,
catch clauses, variable declarations, for-loop bindings). Skip the
replacement when the identifier is shadowed.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): preserve boolean semantics for useFetch conditional fetching

The rewrite function transformed:
- `promise` → `!promise?.resolved`
- `!promise` → `promise?.resolved`

This inverts boolean semantics when useFetch returns undefined (null args
for conditional fetching). `undefined?.resolved` evaluates to `undefined`
via optional chaining, so `!undefined` is `true` — but the original
`if (promise)` with `undefined` was `false`.

Fix by using strict equality against `false`:
- `promise` → `promise?.resolved === false`
- `!promise` → `promise?.resolved !== false`

This preserves the original semantics for all three states: in-flight
promise (resolved=false), resolved promise (resolved=true), and disabled
fetch (undefined).

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): detect top-level var/function/class bindings in scopeBindings

scopeBindings only collected local names from import declarations, missing
top-level variable, function, and class declarations. This caused
resolveLocal to miss naming conflicts (e.g. const Union = someValue),
producing invalid code with duplicate bindings.

Now collects bindings from:
- Top-level VariableDeclarations (including destructured patterns)
- Top-level FunctionDeclarations
- Top-level ClassDeclarations
- Exported variants of all the above

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* fix(codemod): detect FunctionDeclaration/ClassDeclaration shadowing in isShadowed

The isShadowed function only checked VariableDeclaration statements when
scanning BlockStatement/Program nodes. This missed FunctionDeclaration and
ClassDeclaration names that also create bindings in block scope.

When a local function or class with the same name as the import alias
(e.g. 'schema') existed in an enclosing block, isShadowed incorrectly
returned false, causing the codemod to transform references to those
local bindings.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* Fix transformUseFetch rewriting shadowed variables in nested callbacks

transformUseFetch searched the entire enclosing function for truthiness
checks on the useFetch variable name, but never checked whether that
name was shadowed by a parameter or local variable in a nested function
or callback. This caused incorrect rewrites like adding .resolved to a
callback parameter that happened to share the same name.

Fix: reuse the existing isShadowed() helper (already used by
transformSchemaImports) with an optional stopNode parameter to limit the
shadow walk to scopes between the found expression and the function
where useFetch was declared.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* Guard transformPaths with @data-client import check

transformPaths was transforming every StringLiteral with a 'path' property
key in the entire file, regardless of whether the file uses @data-client.
This could silently corrupt path strings from unrelated libraries (React
Router, Express, etc.) like /:param? → {/:param}.

Add the same DATA_CLIENT_PACKAGES import guard that transformUseFetch
already uses, so transformPaths only runs on files that actually import
from @data-client/endpoint or @data-client/rest.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

* Fix resolveLocal collision: verify 'Schema'+name is also free of conflicts

resolveLocal prefixed a name with 'Schema' when it collided with a JS
global or scope binding, but never verified the resulting identifier was
itself free of collisions. If the user had an existing binding named
e.g. SchemaUnion, the codemod would silently produce a conflicting
identifier.

Now the function loops, prepending '_' to the candidate until it finds
an identifier that doesn't collide with JS_GLOBALS or scopeBindings.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
@ntucker ntucker merged commit c342ea6 into master Mar 30, 2026
23 checks passed
@ntucker ntucker deleted the docs/v016-codemod branch March 30, 2026 17:24
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.

2 participants